From 04c1ed2917d856ce4837f6afc787f8564ec4780f Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Wed, 3 Jun 2026 19:37:20 +0300 Subject: [PATCH 1/4] feat(plugins): Add Sideload Plugin System with External Repo Support --- app/build.gradle.kts | 44 +- .../com/arflix/tv/core/plugin/PluginSafety.kt | 51 + .../arflix/tv/core/plugin/TestDiagnostics.kt | 18 + .../arflix/tv/data/local/PluginDataStore.kt | 249 ++++ .../main/kotlin/com/arflix/tv/di/AppModule.kt | 8 + .../com/arflix/tv/domain/model/Plugin.kt | 149 ++ .../tv/ui/screens/plugin/PluginScreen.kt | 270 ++++ .../tv/ui/screens/plugin/PluginUiState.kt | 63 + .../tv/ui/screens/plugin/PluginViewModel.kt | 285 ++++ .../tv/ui/screens/settings/SettingsScreen.kt | 13 + app/src/main/res/values/strings.xml | 29 + .../tv/core/runtime/PluginRuntimeHooks.kt | 12 + .../arflix/tv/core/plugin/PluginManager.kt | 1116 ++++++++++++++ .../arflix/tv/core/plugin/PluginRuntime.kt | 1322 +++++++++++++++++ .../cloudstream/ExternalExtensionLoader.kt | 761 ++++++++++ .../cloudstream/ExternalExtensionRunner.kt | 906 +++++++++++ .../cloudstream/ExternalExtractorRegistry.kt | 85 ++ .../plugin/cloudstream/ExternalRepoParser.kt | 147 ++ .../plugin/cloudstream/TvTypeExtensions.kt | 14 + .../tv/core/runtime/PluginRuntimeHooks.kt | 77 + .../tv/core/tmdb/TmdbMetadataService.kt | 19 + .../com/arflix/tv/core/tmdb/TmdbService.kt | 11 + .../tv/data/local/ProfileDataStoreFactory.kt | 20 + .../com/arflix/tv/domain/model/ContentType.kt | 6 + .../lagradost/cloudstream3/AcraApplication.kt | 20 + .../lagradost/cloudstream3/plugins/Plugin.kt | 66 + .../cloudstream3/plugins/PluginManagerStub.kt | 16 + benchmark/build.gradle.kts | 11 +- build.gradle.kts | 15 +- gradle.properties | 2 + 30 files changed, 5789 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/core/plugin/TestDiagnostics.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/PluginDataStore.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/domain/model/Plugin.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginUiState.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginViewModel.kt create mode 100644 app/src/play/kotlin/com/arflix/tv/core/runtime/PluginRuntimeHooks.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginRuntime.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtensionLoader.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtensionRunner.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtractorRegistry.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalRepoParser.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/TvTypeExtensions.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/runtime/PluginRuntimeHooks.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/tmdb/TmdbMetadataService.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/core/tmdb/TmdbService.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/data/local/ProfileDataStoreFactory.kt create mode 100644 app/src/sideload/kotlin/com/arflix/tv/domain/model/ContentType.kt create mode 100644 app/src/sideload/kotlin/com/lagradost/cloudstream3/AcraApplication.kt create mode 100644 app/src/sideload/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt create mode 100644 app/src/sideload/kotlin/com/lagradost/cloudstream3/plugins/PluginManagerStub.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2df2653a..3f75244a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,7 +20,7 @@ plugins { android { namespace = "com.arflix.tv" - compileSdk = 35 + compileSdk = 36 flavorDimensions += "distribution" @@ -53,10 +53,12 @@ android { create("play") { dimension = "distribution" buildConfigField("Boolean", "SELF_UPDATE_ENABLED", "false") + buildConfigField("Boolean", "FEATURE_PLUGINS_ENABLED", "false") } create("sideload") { dimension = "distribution" buildConfigField("Boolean", "SELF_UPDATE_ENABLED", "true") + buildConfigField("Boolean", "FEATURE_PLUGINS_ENABLED", "true") } } @@ -137,9 +139,7 @@ android { isCoreLibraryDesugaringEnabled = true } - kotlinOptions { - jvmTarget = "17" - } + buildFeatures { compose = true @@ -151,8 +151,7 @@ android { excludes += setOf( "/META-INF/{AL2.0,LGPL2.1}", "/META-INF/LICENSE*", - "/META-INF/NOTICE*", - ) + "/META-INF/NOTICE*", "META-INF/versions/9/OSGI-INF/MANIFEST.MF") } jniLibs { useLegacyPackaging = false // Required for 16KB page size support @@ -218,8 +217,8 @@ dependencies { // with "Unable to read Kotlin metadata due to unsupported metadata // version" because Hilt parses generated `@Module` classes that carry // Kotlin 2.1's newer metadata format. - implementation("com.google.dagger:hilt-android:2.54") - ksp("com.google.dagger:hilt-compiler:2.54") + implementation("com.google.dagger:hilt-android:2.57") + ksp("com.google.dagger:hilt-compiler:2.57") implementation("androidx.hilt:hilt-navigation-compose:1.1.0") // Leanback (TV compliance, browse fragments if needed) @@ -405,3 +404,32 @@ detekt { // Don't fail build on issues (use baseline instead) ignoreFailures = true } + + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} + +dependencies { + ksp("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0") + annotationProcessor("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0") + + // Plugin system dependencies (Sideload flavor only) + add("sideloadImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) + add("sideloadImplementation", "com.fasterxml.jackson.core:jackson-databind:2.17.0") + add("sideloadImplementation", "com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") + add("sideloadImplementation", "com.github.Blatzar:NiceHttp:0.4.11") + add("sideloadImplementation", "org.conscrypt:conscrypt-android:2.5.3") + add("sideloadImplementation", "com.github.recloudstream.cloudstream:library:v4.7.0") { + exclude(group = "org.mozilla", module = "rhino") + } + add("sideloadImplementation", "org.webjars.npm:crypto-js:4.2.0") + + // Moshi - used in both sideload plugins and main data store + implementation("com.squareup.moshi:moshi:1.15.1") + implementation("com.squareup.moshi:moshi-kotlin:1.15.1") + ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1") +} diff --git a/app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt b/app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt new file mode 100644 index 00000000..7b178a0f --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt @@ -0,0 +1,51 @@ +package com.arflix.tv.core.plugin + +internal object PluginSafety { + + // List of known dangerous package names/prefixes that should be blocked + private val BLOCKED_PACKAGES = setOf( + "com.google", + "android", + "java", + "javax", + "kotlin", + "com.arflix.tv.core" // Prevent plugins from shadowing our own core logic + ) + + // Allowed plugin file extensions + private val ALLOWED_EXTENSIONS = setOf("cs3", "apk", "dex", "js") + + /** + * Validates a plugin based on its metadata before allowing it to load. + */ + fun isSafeToLoad( + pluginName: String?, + pluginPackage: String?, + filename: String? + ): Boolean { + // Basic presence checks + if (pluginName.isNullOrBlank() || filename.isNullOrBlank()) { + return false + } + + // Validate extension + val ext = filename.substringAfterLast('.', "").lowercase() + if (ext !in ALLOWED_EXTENSIONS) { + return false + } + + // Validate package name (if provided) to prevent namespace shadowing + if (pluginPackage != null) { + if (BLOCKED_PACKAGES.any { pluginPackage.startsWith(it, ignoreCase = true) }) { + return false + } + } + + // No path traversal allowed + if (filename.contains("/") || filename.contains("\\") || filename.contains("..")) { + return false + } + + return true + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/core/plugin/TestDiagnostics.kt b/app/src/main/kotlin/com/arflix/tv/core/plugin/TestDiagnostics.kt new file mode 100644 index 00000000..196cbf9f --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/core/plugin/TestDiagnostics.kt @@ -0,0 +1,18 @@ +package com.arflix.tv.core.plugin + +import android.util.Log + +private const val TAG = "TestDiagnostics" + +/** + * Collects diagnostic steps during a scraper test run. + * Each step is a status line like "DEX loaded: 2 MainAPIs" or "Search: 5 results for 'The Matrix'". + */ +data class TestDiagnostics( + val steps: MutableList = mutableListOf() +) { + fun addStep(step: String) { + steps.add(step) + Log.d(TAG, step) + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/PluginDataStore.kt b/app/src/main/kotlin/com/arflix/tv/data/local/PluginDataStore.kt new file mode 100644 index 00000000..4a8a8273 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/PluginDataStore.kt @@ -0,0 +1,249 @@ +package com.arflix.tv.data.local + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.arflix.tv.domain.model.PluginRepository +import com.arflix.tv.domain.model.ScraperInfo +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@OptIn(ExperimentalCoroutinesApi::class) +class PluginDataStore @Inject constructor( + @ApplicationContext private val context: Context, + private val moshi: Moshi, + private val factory: ProfileDataStoreFactory +) { + companion object { + private const val FEATURE = "plugin_settings" + } + + private fun effectiveProfileId(): Int { + return 1 + } + + private fun store(profileId: Int = effectiveProfileId()) = + factory.get(profileId, FEATURE) + + private val effectiveProfileIdFlow: Flow = kotlinx.coroutines.flow.flowOf(1) + + private val repositoriesKey = stringPreferencesKey("repositories") + private val scrapersKey = stringPreferencesKey("scrapers") + private val pluginsEnabledKey = booleanPreferencesKey("plugins_enabled") + private val groupStreamsByRepositoryKey = booleanPreferencesKey("group_streams_by_repository") + private val scraperSettingsKey = stringPreferencesKey("scraper_settings") + + private val repoListType = Types.newParameterizedType(List::class.java, PluginRepository::class.java) + private val scraperListType = Types.newParameterizedType(List::class.java, ScraperInfo::class.java) + private val settingsMapType = Types.newParameterizedType( + Map::class.java, + String::class.java, + Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java) + ) + + // Plugin code directory - per-profile + val codeDir: File + get() { + val pid = effectiveProfileId() + val dirName = if (pid == 1) "plugin_code" else "plugin_code_p${pid}" + return File(context.filesDir, dirName) + } + + private suspend fun ensureCodeDir(): File = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + codeDir.also { it.mkdirs() } + } + + // Repositories + val repositories: Flow> = effectiveProfileIdFlow.flatMapLatest { pid -> + factory.get(pid, FEATURE).data.map { prefs -> + prefs[repositoriesKey]?.let { json -> + try { + moshi.adapter>(repoListType).fromJson(json) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } ?: emptyList() + } + } + + suspend fun saveRepositories(repos: List) { + + + val json = moshi.adapter>(repoListType).toJson(repos) + store().edit { prefs -> + prefs[repositoriesKey] = json + } + } + + suspend fun addRepository(repo: PluginRepository) { + + + val current = repositories.first().toMutableList() + current.removeAll { it.id == repo.id } + current.add(repo) + saveRepositories(current) + } + + suspend fun removeRepository(repoId: String) { + + + val current = repositories.first().toMutableList() + current.removeAll { it.id == repoId } + saveRepositories(current) + } + + suspend fun updateRepository(repo: PluginRepository) { + val current = repositories.first().toMutableList() + val index = current.indexOfFirst { it.id == repo.id } + if (index >= 0) { + current[index] = repo + val json = moshi.adapter>(repoListType).toJson(current) + store().edit { prefs -> + prefs[repositoriesKey] = json + } + } + } + + // Scrapers + val scrapers: Flow> = effectiveProfileIdFlow.flatMapLatest { pid -> + factory.get(pid, FEATURE).data.map { prefs -> + prefs[scrapersKey]?.let { json -> + try { + moshi.adapter>(scraperListType).fromJson(json) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } ?: emptyList() + } + } + + suspend fun saveScrapers(scrapers: List) { + + + val json = moshi.adapter>(scraperListType).toJson(scrapers) + store().edit { prefs -> + prefs[scrapersKey] = json + } + } + + suspend fun setScraperEnabled(scraperId: String, enabled: Boolean) { + val current = scrapers.first().toMutableList() + val index = current.indexOfFirst { it.id == scraperId } + if (index >= 0) { + val scraper = current[index] + // Only enable if manifest allows + if (enabled && !scraper.manifestEnabled) return + current[index] = scraper.copy(enabled = enabled) + saveScrapers(current) + } + } + + // Plugins enabled global toggle + val pluginsEnabled: Flow = effectiveProfileIdFlow.flatMapLatest { pid -> + factory.get(pid, FEATURE).data.map { prefs -> + prefs[pluginsEnabledKey] ?: true + } + } + + suspend fun setPluginsEnabled(enabled: Boolean) { + + + store().edit { prefs -> + prefs[pluginsEnabledKey] = enabled + } + } + + val groupStreamsByRepository: Flow = effectiveProfileIdFlow.flatMapLatest { pid -> + factory.get(pid, FEATURE).data.map { prefs -> + prefs[groupStreamsByRepositoryKey] ?: false + } + } + + suspend fun setGroupStreamsByRepository(enabled: Boolean) { + + + store().edit { prefs -> + prefs[groupStreamsByRepositoryKey] = enabled + } + } + + // Scraper code storage + fun getScraperCodeFile(scraperId: String): File { + return File(codeDir, "$scraperId.js") + } + + suspend fun saveScraperCode(scraperId: String, code: String) { + val dir = ensureCodeDir() + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + File(dir, "$scraperId.js").writeText(code) + } + } + + suspend fun getScraperCode(scraperId: String): String? { + return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + val file = File(codeDir, "$scraperId.js") + if (file.exists()) file.readText() else null + } + } + + suspend fun deleteScraperCode(scraperId: String) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + File(codeDir, "$scraperId.js").delete() + } + } + + suspend fun clearAllScraperCode() { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + codeDir.listFiles()?.forEach { it.delete() } + } + } + + // Per-scraper settings + suspend fun getScraperSettings(scraperId: String): Map { + val prefs = store().data.first() + val allSettings = prefs[scraperSettingsKey]?.let { json -> + try { + @Suppress("UNCHECKED_CAST") + moshi.adapter>>(settingsMapType).fromJson(json) ?: emptyMap() + } catch (e: Exception) { + emptyMap() + } + } ?: emptyMap() + + @Suppress("UNCHECKED_CAST") + return allSettings[scraperId] as? Map ?: emptyMap() + } + + suspend fun setScraperSettings(scraperId: String, settings: Map) { + val prefs = store().data.first() + val allSettings = prefs[scraperSettingsKey]?.let { json -> + try { + @Suppress("UNCHECKED_CAST") + moshi.adapter>>(settingsMapType).fromJson(json)?.toMutableMap() + ?: mutableMapOf() + } catch (e: Exception) { + mutableMapOf() + } + } ?: mutableMapOf() + + allSettings[scraperId] = settings + + val json = moshi.adapter>>(settingsMapType).toJson(allSettings) + store().edit { p -> + p[scraperSettingsKey] = json + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 7dfc4363..e917aff7 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -150,4 +150,12 @@ object AppModule { fun provideJikanApi(@Named("jikan") retrofit: Retrofit): com.arflix.tv.data.api.JikanApi { return retrofit.create(com.arflix.tv.data.api.JikanApi::class.java) } + @Provides + @Singleton + fun provideMoshi(): com.squareup.moshi.Moshi { + return com.squareup.moshi.Moshi.Builder() + .add(com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory()) + .build() + } } + diff --git a/app/src/main/kotlin/com/arflix/tv/domain/model/Plugin.kt b/app/src/main/kotlin/com/arflix/tv/domain/model/Plugin.kt new file mode 100644 index 00000000..dc5c4eb9 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/domain/model/Plugin.kt @@ -0,0 +1,149 @@ +package com.arflix.tv.domain.model + +import com.squareup.moshi.JsonClass + +/** + * Repository type distinguishing native JS plugins from external DEX extensions. + */ +enum class RepositoryType { + NUVIO_JS, + EXTERNAL_DEX +} + +/** + * Plugin info returned from Supabase sync, with optional type hint. + */ +data class RemotePluginInfo( + val url: String, + val repoType: String? = null +) + +/** + * Represents a plugin repository containing scrapers + */ +data class PluginRepository( + val id: String, + val name: String, + val url: String, + val description: String? = null, + val enabled: Boolean = true, + val lastUpdated: Long = 0L, + val scraperCount: Int = 0, + val type: RepositoryType = RepositoryType.NUVIO_JS +) + +/** + * Represents manifest.json from a plugin repository + */ +@JsonClass(generateAdapter = true) +data class PluginManifest( + val name: String, + val version: String, + val description: String? = null, + val author: String? = null, + val scrapers: List? = null, + val providers: List? = null +) { + fun getActiveScrapers(): List { + return scrapers ?: providers ?: emptyList() + } +} + +/** + * Scraper info from manifest.json + */ +@JsonClass(generateAdapter = true) +data class ScraperManifestInfo( + val id: String, + val name: String, + val description: String? = null, + val version: String, + val filename: String, + val supportedTypes: List = listOf("movie", "tv"), + val enabled: Boolean = true, + val logo: String? = null, + val contentLanguage: List? = null, + val supportedPlatforms: List? = null, + val disabledPlatforms: List? = null, + val formats: List? = null, + val supportedFormats: List? = null, + val supportsExternalPlayer: Boolean? = null, + val limited: Boolean? = null +) + +/** + * Installed scraper info with runtime state + */ +data class ScraperInfo( + val id: String, + val name: String, + val description: String, + val version: String, + val filename: String, + val supportedTypes: List, + val enabled: Boolean, + val manifestEnabled: Boolean, + val logo: String?, + val contentLanguage: List, + val repositoryId: String, + val formats: List?, + val type: RepositoryType = RepositoryType.NUVIO_JS +) { + fun supportsType(type: String): Boolean { + val normalizedType = when (type.lowercase()) { + "series", "other" -> "tv" + else -> type.lowercase() + } + return supportedTypes.map { it.lowercase() }.contains(normalizedType) + } +} + +/** + * Result from a local scraper execution + */ +data class LocalScraperResult( + val title: String, + val name: String? = null, + val url: String, + val quality: String? = null, + val size: String? = null, + val language: String? = null, + val provider: String? = null, + val type: String? = null, + val seeders: Int? = null, + val peers: Int? = null, + val infoHash: String? = null, + val headers: Map? = null +) + +/** + * Manifest format for external extension repositories. + */ +@JsonClass(generateAdapter = true) +data class ExternalRepoManifest( + val name: String, + val description: String? = null, + val manifestVersion: Int = 1, + val pluginLists: List +) + +/** + * Entry for an individual extension in an external repository's plugins list. + */ +@JsonClass(generateAdapter = true) +data class ExternalPluginEntry( + val name: String, + val internalName: String, + val description: String? = null, + val version: Int = 1, + val apiVersion: Int = 1, + val status: Int = 1, + val authors: List? = null, + val tvTypes: List? = null, + val iconUrl: String? = null, + val url: String, + val fileSize: Long? = null, + val repositoryUrl: String? = null +) + + diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt new file mode 100644 index 00000000..db1d3c81 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt @@ -0,0 +1,270 @@ +package com.arflix.tv.ui.screens.plugin + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.clickable + +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.material3.Text +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.ClickableSurfaceDefaults + +@Composable +fun PluginScreen( + viewModel: PluginViewModel = hiltViewModel(), + onBackPressed: () -> Unit, + onNavigateToSection: (() -> Unit)? = null +) { + val uiState by viewModel.uiState.collectAsState() + var showAddDialog by remember { mutableStateOf(false) } + val addButtonFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(100) + try { + addButtonFocusRequester.requestFocus() + } catch (e: Exception) {} + } + + Column( + modifier = Modifier + .padding(bottom = 80.dp) + .fillMaxWidth() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft) { + onNavigateToSection?.invoke() + return@onPreviewKeyEvent onNavigateToSection != null + } + false + } + ) { + Text("Plugins (Testing)", color = Color.White, style = MaterialTheme.typography.headlineLarge) + Spacer(modifier = Modifier.height(16.dp)) + uiState.errorMessage?.let { msg -> + Text(msg, color = Color.Red) + Spacer(modifier = Modifier.height(16.dp)) + } + + // Full width Add Button to easily catch focus + Surface( + onClick = { showAddDialog = true }, + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF2B2B2B), + focusedContainerColor = Color(0xFFE91E63) + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), + modifier = Modifier.fillMaxWidth().focusRequester(addButtonFocusRequester) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Add Repository", color = Color.White, style = MaterialTheme.typography.titleMedium) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (uiState.repositories.isNotEmpty()) { + Text("Installed Repositories", color = Color.White, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + uiState.repositories.forEach { repo -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text("💠 ${repo.name}", color = Color.Cyan) + Spacer(modifier = Modifier.width(16.dp)) + Surface( + onClick = { viewModel.onEvent(PluginUiEvent.RemoveRepository(repo.id)) }, + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.White.copy(alpha = 0.1f) + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(4.dp)) + ) { + Text("🗑️ Delete", color = Color.Red, modifier = Modifier.padding(4.dp)) + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + Spacer(modifier = Modifier.height(24.dp)) + } + + Text("Installed Scrapers", color = Color.White, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + + if (uiState.scrapers.isEmpty()) { + Text("No scrapers installed.", color = Color.Gray) + } + + if (uiState.scrapers.isNotEmpty()) { + uiState.scrapers.forEach { scraper -> + // Make the entire row focusable and clickable + Surface( + onClick = { viewModel.onEvent(PluginUiEvent.ToggleScraper(scraper.id, !scraper.enabled)) }, + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.White.copy(alpha = 0.1f) + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text(scraper.name, color = Color.White, modifier = Modifier.weight(1f)) + + // Custom TV-safe Switch visualization (doesn't trap focus itself) + Box( + modifier = Modifier + .width(44.dp) + .height(24.dp) + .background( + color = if (scraper.enabled) Color(0xFF4CAF50) else Color.White.copy(alpha = 0.2f), + shape = RoundedCornerShape(13.dp) + ) + .padding(3.dp), + contentAlignment = if (scraper.enabled) Alignment.CenterEnd else Alignment.CenterStart + ) { + Box( + modifier = Modifier + .size(18.dp) + .background(color = Color.White, shape = RoundedCornerShape(10.dp)) + ) + } + } + } + } + } + } + + if (showAddDialog) { + AddRepoDialog( + onSave = { url -> + viewModel.onEvent(PluginUiEvent.AddRepository(url)) + showAddDialog = false + try { addButtonFocusRequester.requestFocus() } catch (e: Exception) {} + }, + onDismiss = { + showAddDialog = false + try { addButtonFocusRequester.requestFocus() } catch (e: Exception) {} + } + ) + } +} + +@Composable +fun AddRepoDialog( + onSave: (String) -> Unit, + onDismiss: () -> Unit +) { + var value by remember { mutableStateOf("") } + val inputFocusRequester = remember { FocusRequester() } + val saveFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(200) + try { inputFocusRequester.requestFocus() } catch (_: Exception) {} + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && + (event.key == Key.Back || event.key == Key.Escape)) { + onDismiss() + return@onPreviewKeyEvent true + } + false + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .width(520.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFF1E1E1E)) + .clickable { /* absorb clicks */ } + ) { + Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) { + Text("Add Plugin Repository", style = MaterialTheme.typography.titleLarge, color = Color.White) + Spacer(modifier = Modifier.height(16.dp)) + + androidx.compose.material3.OutlinedTextField( + value = value, + onValueChange = { value = it }, + singleLine = true, + label = { androidx.compose.material3.Text("Repository URL") }, + modifier = Modifier.fillMaxWidth().focusRequester(inputFocusRequester), + colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color(0xFFE91E63), + unfocusedBorderColor = Color.Gray, + focusedLabelColor = Color(0xFFE91E63), + unfocusedLabelColor = Color.Gray + ) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + onClick = onDismiss, + modifier = Modifier.weight(1f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF2B2B2B), + focusedContainerColor = Color(0xFF3B3B3B) + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)) + ) { + Text( + text = "Cancel", + modifier = Modifier.padding(vertical = 12.dp).fillMaxWidth(), + textAlign = TextAlign.Center, + color = Color.White + ) + } + + Surface( + onClick = { onSave(value) }, + modifier = Modifier.weight(1f).focusRequester(saveFocusRequester), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFFE91E63), + focusedContainerColor = Color(0xFFFF4081) + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)) + ) { + Text( + text = "Add", + modifier = Modifier.padding(vertical = 12.dp).fillMaxWidth(), + textAlign = TextAlign.Center, + color = Color.White + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginUiState.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginUiState.kt new file mode 100644 index 00000000..23a1ebe3 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginUiState.kt @@ -0,0 +1,63 @@ +package com.arflix.tv.ui.screens.plugin + +import android.graphics.Bitmap +import com.arflix.tv.core.plugin.TestDiagnostics +import com.arflix.tv.domain.model.LocalScraperResult +import com.arflix.tv.domain.model.PluginRepository +import com.arflix.tv.domain.model.ScraperInfo + +data class PluginUiState( + val pluginsEnabled: Boolean = true, + val groupStreamsByRepository: Boolean = false, + val repositories: List = emptyList(), + val scrapers: List = emptyList(), + val isLoading: Boolean = false, + val isAddingRepo: Boolean = false, + val isTesting: Boolean = false, + val testResults: List? = null, + val testDiagnostics: TestDiagnostics? = null, + val testScraperId: String? = null, + val errorMessage: String? = null, + val successMessage: String? = null, + // QR mode + val isQrModeActive: Boolean = false, + val qrCodeBitmap: Bitmap? = null, + val serverUrl: String? = null, + // Pending change from phone + val pendingRepoChange: PendingRepoChangeInfo? = null, + // Pending scraper enable confirmation + val pendingScraperEnable: PendingScraperEnableInfo? = null +) + +data class PendingRepoChangeInfo( + val changeId: String, + val proposedUrls: List, + val addedUrls: List, + val removedUrls: List, + val isApplying: Boolean = false +) + +data class PendingScraperEnableInfo( + val scraperId: String, + val scraperName: String +) + +sealed interface PluginUiEvent { + data class AddRepository(val url: String) : PluginUiEvent + data class RemoveRepository(val repoId: String) : PluginUiEvent + data class RefreshRepository(val repoId: String) : PluginUiEvent + data class ToggleScraper(val scraperId: String, val enabled: Boolean) : PluginUiEvent + data class ToggleAllScrapersForRepo(val repoId: String, val enabled: Boolean) : PluginUiEvent + data class TestScraper(val scraperId: String) : PluginUiEvent + data class SetPluginsEnabled(val enabled: Boolean) : PluginUiEvent + data class SetGroupStreamsByRepository(val enabled: Boolean) : PluginUiEvent + object ClearTestResults : PluginUiEvent + object ClearError : PluginUiEvent + object ClearSuccess : PluginUiEvent + object StartQrMode : PluginUiEvent + object StopQrMode : PluginUiEvent + object ConfirmPendingRepoChange : PluginUiEvent + object RejectPendingRepoChange : PluginUiEvent + object ConfirmPendingScraperEnable : PluginUiEvent + object DismissPendingScraperEnable : PluginUiEvent +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginViewModel.kt new file mode 100644 index 00000000..d8665571 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginViewModel.kt @@ -0,0 +1,285 @@ +package com.arflix.tv.ui.screens.plugin + +import com.arflix.tv.R + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope + +import com.arflix.tv.core.plugin.PluginManager + + + +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PluginViewModel @Inject constructor( + private val pluginManager: PluginManager, + @ApplicationContext private val context: Context +) : ViewModel() { + + private val _uiState = MutableStateFlow(PluginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val isReadOnly: Boolean + get() { + return false + } + + private var repoServer: Any? = null + private var logoBytes: ByteArray? = null + + init { + loadLogoBytes() + observePluginData() + } + + private fun loadLogoBytes() { + try { + val inputStream = context.resources.openRawResource(0) + logoBytes = inputStream.use { it.readBytes() } + } catch (_: Exception) { } + } + + private fun observePluginData() { + viewModelScope.launch { + combine( + pluginManager.pluginsEnabled, + pluginManager.groupStreamsByRepository, + pluginManager.repositories, + pluginManager.scrapers + ) { enabled, groupStreamsByRepository, repos, scrapers -> + PluginUiState( + pluginsEnabled = enabled, + groupStreamsByRepository = groupStreamsByRepository, + repositories = repos, + scrapers = scrapers + ) + }.collect { nextState -> + val visibleScrapers = if (isReadOnly) { + nextState.scrapers.filter { it.enabled } + } else { + nextState.scrapers + } + _uiState.update { + it.copy( + pluginsEnabled = nextState.pluginsEnabled, + groupStreamsByRepository = nextState.groupStreamsByRepository, + repositories = nextState.repositories, + scrapers = visibleScrapers + ) + } + } + } + } + + fun onEvent(event: PluginUiEvent) { + when (event) { + is PluginUiEvent.AddRepository -> addRepository(event.url) + is PluginUiEvent.RemoveRepository -> removeRepository(event.repoId) + is PluginUiEvent.RefreshRepository -> refreshRepository(event.repoId) + is PluginUiEvent.ToggleScraper -> toggleScraper(event.scraperId, event.enabled) + is PluginUiEvent.ToggleAllScrapersForRepo -> toggleAllScrapersForRepo(event.repoId, event.enabled) + is PluginUiEvent.TestScraper -> testScraper(event.scraperId) + is PluginUiEvent.SetPluginsEnabled -> setPluginsEnabled(event.enabled) + is PluginUiEvent.SetGroupStreamsByRepository -> setGroupStreamsByRepository(event.enabled) + PluginUiEvent.ClearTestResults -> _uiState.update { it.copy(testResults = null, testDiagnostics = null, testScraperId = null) } + PluginUiEvent.ClearError -> _uiState.update { it.copy(errorMessage = null) } + PluginUiEvent.ClearSuccess -> _uiState.update { it.copy(successMessage = null) } + PluginUiEvent.StartQrMode -> startQrMode() + PluginUiEvent.StopQrMode -> stopQrMode() + PluginUiEvent.ConfirmPendingRepoChange -> confirmPendingRepoChange() + PluginUiEvent.RejectPendingRepoChange -> rejectPendingRepoChange() + PluginUiEvent.ConfirmPendingScraperEnable -> confirmPendingScraperEnable() + PluginUiEvent.DismissPendingScraperEnable -> dismissPendingScraperEnable() + } + } + + private fun addRepository(url: String) { + if (url.isBlank()) { + _uiState.update { it.copy(errorMessage = "Error") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isAddingRepo = true, errorMessage = null) } + + val result = pluginManager.addRepository(url) + + result.fold( + onSuccess = { repo -> + _uiState.update { + it.copy( + isAddingRepo = false, + successMessage = context.getString( + R.string.plugin_repo_added_with_providers, + repo.scraperCount + ) + ) + } + }, + onFailure = { e -> + _uiState.update { + it.copy( + isAddingRepo = false, + errorMessage = context.getString(R.string.plugin_error_add_repo, e.message ?: "") + ) + } + } + ) + } + } + + private fun removeRepository(repoId: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + pluginManager.removeRepository(repoId) + _uiState.update { + it.copy( + isLoading = false, + successMessage = "Error" + ) + } + } + } + + private fun refreshRepository(repoId: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + val result = pluginManager.refreshRepository(repoId) + + result.fold( + onSuccess = { + _uiState.update { + it.copy( + isLoading = false, + successMessage = "Error" + ) + } + }, + onFailure = { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = context.getString(R.string.plugin_error_refresh, e.message ?: "") + ) + } + } + ) + } + } + + private fun toggleScraper(scraperId: String, enabled: Boolean) { + val scraper = _uiState.value.scrapers.firstOrNull { it.id == scraperId } + if (enabled && scraper != null) { + _uiState.update { + it.copy( + pendingScraperEnable = PendingScraperEnableInfo( + scraperId = scraper.id, + scraperName = scraper.name + ) + ) + } + return + } + + viewModelScope.launch { + pluginManager.toggleScraper(scraperId, enabled) + } + } + + private fun toggleAllScrapersForRepo(repoId: String, enabled: Boolean) { + viewModelScope.launch { + pluginManager.toggleAllScrapersForRepo(repoId, enabled) + } + } + + private fun confirmPendingScraperEnable() { + val pending = _uiState.value.pendingScraperEnable ?: return + _uiState.update { it.copy(pendingScraperEnable = null) } + viewModelScope.launch { + pluginManager.toggleScraper(pending.scraperId, true) + } + } + + private fun dismissPendingScraperEnable() { + _uiState.update { it.copy(pendingScraperEnable = null) } + } + + private fun setPluginsEnabled(enabled: Boolean) { + if (isReadOnly) return + viewModelScope.launch { + pluginManager.setPluginsEnabled(enabled) + } + } + + private fun setGroupStreamsByRepository(enabled: Boolean) { + if (isReadOnly) return + viewModelScope.launch { + pluginManager.setGroupStreamsByRepository(enabled) + } + } + + private fun testScraper(scraperId: String) { + viewModelScope.launch { + _uiState.update { it.copy(isTesting = true, testScraperId = scraperId, testResults = null, testDiagnostics = null) } + + val result = pluginManager.testScraper(scraperId) + + result.fold( + onSuccess = { (results, diagnostics) -> + _uiState.update { + it.copy( + isTesting = false, + testResults = results, + testDiagnostics = diagnostics, + successMessage = if (results.isEmpty()) { + "Error" + } else { + context.getString(R.string.plugin_test_found_streams, results.size) + } + ) + } + }, + onFailure = { e -> + _uiState.update { + it.copy( + isTesting = false, + testResults = emptyList(), + testDiagnostics = null, + errorMessage = context.getString( + R.string.plugin_error_test, + e.message ?: "Error" + ) + ) + } + } + ) + } + } + + private fun normalizeUrlForComparison(url: String): String { + return url.trim().trimEnd('/').lowercase() + } + + private fun startQrMode() {} + fun stopQrMode() {} + private fun confirmPendingRepoChange() {} + private fun rejectPendingRepoChange() {} + override fun onCleared() { + super.onCleared() + } +} + + + diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 26d174b9..8ad6641b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -372,6 +372,9 @@ fun SettingsScreen( add("stremio") add("catalogs") add("home_server") + if (BuildConfig.FEATURE_PLUGINS_ENABLED) { + add("plugins") + } add("appearance") add("network") } @@ -644,6 +647,7 @@ fun SettingsScreen( if (event.type == KeyEventType.KeyDown) { val currentSection = sections.getOrNull(sectionIndex).orEmpty() + if (currentSection == "plugins" && activeZone == Zone.CONTENT) return@onPreviewKeyEvent false val focusedStremioAddon = stremioAddons.getOrNull(contentFocusIndex) val focusedStremioAddonCanDelete = focusedStremioAddon?.let { addon -> !(addon.id == "opensubtitles" && addon.type == com.arflix.tv.data.model.AddonType.SUBTITLE) @@ -1465,6 +1469,14 @@ fun SettingsScreen( onDeleteAddon = { viewModel.removeAddon(it) }, onAddCustomAddon = { showCustomAddonInput = true } ) + "plugins" -> { + com.arflix.tv.ui.screens.plugin.PluginScreen( + onBackPressed = { activeZone = Zone.SECTION }, + onNavigateToSection = { + activeZone = Zone.SECTION + } + ) + } "accounts" -> AccountsSettings( isCloudAuthenticated = uiState.isLoggedIn, cloudEmail = uiState.accountEmail, @@ -8870,3 +8882,4 @@ private fun IptvCategoriesSettings( } } } + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c163af2c..e0aea47d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,4 +252,33 @@ Scan with your phone — choose Groq or Gemini Smoother Scrolling Use premium smooth transitions when navigating poster rows + + %1$d Providers + Updated: %1$s + Refresh Repository + Remove Repository + Version: %1$s + Test Plugin + Test + Hide Diagnostics + Show Diagnostics + Results: %1$d + + %1$d more + Unknown error + Invalid Repository URL + Repository added with %1$d providers + Error adding repository + Repository removed + Repository refreshed successfully + Error refreshing repository + No results found + Found %1$d streams + Error testing plugin + + Confirm + Warning + This plugin may be risky. + Cancel + Enable + diff --git a/app/src/play/kotlin/com/arflix/tv/core/runtime/PluginRuntimeHooks.kt b/app/src/play/kotlin/com/arflix/tv/core/runtime/PluginRuntimeHooks.kt new file mode 100644 index 00000000..eb327b36 --- /dev/null +++ b/app/src/play/kotlin/com/arflix/tv/core/runtime/PluginRuntimeHooks.kt @@ -0,0 +1,12 @@ +package com.arflix.tv.core.runtime + +import android.app.Activity +import android.app.Application + +object PluginRuntimeHooks { + fun onApplicationCreate(application: Application) = Unit + + fun onActivityCreate(activity: Activity) = Unit + + fun onActivityDestroy() = Unit +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt new file mode 100644 index 00000000..da74a268 --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt @@ -0,0 +1,1116 @@ +package com.arflix.tv.core.plugin + +import android.util.Log +import com.arflix.tv.core.plugin.cloudstream.toNuvioType +import com.arflix.tv.core.plugin.cloudstream.tvTypeFromString +import com.arflix.tv.core.plugin.cloudstream.ExternalExtensionLoader +import com.arflix.tv.core.plugin.cloudstream.ExternalExtensionRunner +import com.arflix.tv.core.plugin.cloudstream.ExternalRepoParser +import com.arflix.tv.data.local.PluginDataStore +import com.arflix.tv.domain.model.ExternalPluginEntry +import com.arflix.tv.domain.model.LocalScraperResult +import com.arflix.tv.domain.model.PluginManifest +import com.arflix.tv.domain.model.PluginRepository +import com.arflix.tv.domain.model.RemotePluginInfo +import com.arflix.tv.domain.model.RepositoryType +import com.arflix.tv.domain.model.ScraperInfo +import com.arflix.tv.domain.model.ScraperManifestInfo +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "PluginManager" +// Scrapers are network-bound, not CPU-bound. Running more concurrently lets slow +// providers overlap with fast ones instead of batching in groups of 5. OkHttp's +// dispatcher + the providers' own internal parallelism stay the real bottleneck. +private const val MAX_CONCURRENT_SCRAPERS = 10 +private const val MAX_RESULT_ITEMS = 150 +private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L +// Outer safety-net timeout for scrapers. The runner now internally caps loadLinks +// at 60s and returns partial links. This outer cap only fires if the runner hangs +// outside of loadLinks (e.g. slow TMDB enrichment, slow search). Generous to avoid +// cancelling the runner's coroutine before it can return accumulated links. +private const val SCRAPER_TIMEOUT_MS = 120_000L +private const val MANIFEST_SUFFIX = "/manifest.json" + +@Singleton +class PluginManager @Inject constructor( + private val dataStore: PluginDataStore, + private val runtime: PluginRuntime, + private val externalRepoParser: ExternalRepoParser, + private val externalExtensionLoader: ExternalExtensionLoader, + private val externalExtensionRunner: ExternalExtensionRunner +) { + private val moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + + private val manifestAdapter = moshi.adapter(PluginManifest::class.java) + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private fun sha256Hex(text: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8)) + val sb = StringBuilder(digest.size * 2) + for (b in digest) { + sb.append(((b.toInt() shr 4) and 0xF).toString(16)) + sb.append((b.toInt() and 0xF).toString(16)) + } + return sb.toString() + } + + /** + * Normalize custom protocol schemes to https://. + * External repos often use schemes like "cloudstreamrepo://" or "stremio://". + */ + private fun sanitizeScheme(url: String): String { + val trimmed = url.trim() + // Replace any non-http(s) scheme with https:// + val schemeEnd = trimmed.indexOf("://") + if (schemeEnd > 0) { + val scheme = trimmed.substring(0, schemeEnd).lowercase() + if (scheme != "http" && scheme != "https") { + return "https://${trimmed.substring(schemeEnd + 3)}" + } + } + return trimmed + } + + /** + * Check if the input looks like a short code rather than a URL. + * Short codes are alphanumeric strings without slashes, dots (other than in a domain), + * or protocol schemes — e.g. "cspr", "0094", "megarepo". + */ + private fun isShortCode(input: String): Boolean { + val trimmed = input.trim() + if (trimmed.isEmpty()) return false + // Has a scheme → not a short code + if (trimmed.contains("://")) return false + // Has path separators or dots → likely a URL or domain + if (trimmed.contains("/") || trimmed.contains(".")) return false + // Only alphanumeric + hyphens + underscores → short code + return trimmed.all { it.isLetterOrDigit() || it == '-' || it == '_' } + } + + /** + * Resolve a short code by following the redirect from cutt.ly/{code}. + * Returns the resolved URL or null if resolution fails. + */ + private fun resolveShortCode(code: String): String? { + return try { + // Use a client that does NOT follow redirects so we can read the Location header + val noRedirectClient = httpClient.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + + val request = Request.Builder() + .url("https://cutt.ly/$code") + .header("User-Agent", "NuvioTV/1.0") + .build() + + noRedirectClient.newCall(request).execute().use { response -> + if (response.code in 301..302) { + val location = response.header("Location") + if (!location.isNullOrBlank()) { + Log.d(TAG, "Short code '$code' resolved to: $location") + return sanitizeScheme(location) + } + } + // Some shorteners return 200 with a meta refresh or JS redirect + // Try following redirects as fallback + Log.d(TAG, "Short code '$code' returned ${response.code}, trying with redirects") + null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to resolve short code '$code': ${e.message}") + null + } ?: try { + // Fallback: follow redirects and see where we end up + val request = Request.Builder() + .url("https://cutt.ly/$code") + .header("User-Agent", "NuvioTV/1.0") + .build() + + httpClient.newCall(request).execute().use { response -> + val finalUrl = response.request.url.toString() + if (finalUrl != "https://cutt.ly/$code" && response.isSuccessful) { + Log.d(TAG, "Short code '$code' resolved via redirect chain to: $finalUrl") + sanitizeScheme(finalUrl) + } else { + null + } + } + } catch (e: Exception) { + Log.e(TAG, "Fallback resolve for short code '$code' failed: ${e.message}") + null + } + } + + private fun canonicalizeManifestUrl(url: String): String { + val trimmed = sanitizeScheme(url).trimEnd('/') + return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) { + trimmed + } else { + "$trimmed$MANIFEST_SUFFIX" + } + } + + /** + * Canonicalize a URL for deduplication. For NuvioTV-style URLs (that don't end in .json), + * appends /manifest.json. For URLs already ending in .json (external repos), keeps them as-is. + */ + private fun canonicalizeRepoUrl(url: String): String { + val trimmed = sanitizeScheme(url).trimEnd('/') + // If URL already ends with a .json file, it's likely an external repo URL — keep as-is + if (trimmed.substringAfterLast("/").endsWith(".json", ignoreCase = true)) { + return trimmed + } + // Otherwise canonicalize as NuvioTV manifest + return canonicalizeManifestUrl(trimmed) + } + + private fun normalizeUrl(url: String): String = canonicalizeRepoUrl(url).lowercase() + + // Single-flight map to prevent duplicate scraper executions + private val inFlightScrapers = ConcurrentHashMap>>() + + // Semaphore to limit concurrent scrapers + private val scraperSemaphore = Semaphore(MAX_CONCURRENT_SCRAPERS) + + + @OptIn(ExperimentalCoroutinesApi::class) + private val pluginDispatcher: CoroutineDispatcher = + Executors.newFixedThreadPool(MAX_CONCURRENT_SCRAPERS) { runnable -> + Thread(runnable, "plugin-worker").apply { + priority = Thread.MIN_PRIORITY + isDaemon = true + } + }.asCoroutineDispatcher() + + // Flow of all repositories + val repositories: Flow> = dataStore.repositories + + // Flow of all scrapers + val scrapers: Flow> = dataStore.scrapers + + // Flow of plugins enabled state + val pluginsEnabled: Flow = dataStore.pluginsEnabled + + val groupStreamsByRepository: Flow = dataStore.groupStreamsByRepository + + private val syncScope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + Dispatchers.IO + ) + + var isSyncingFromRemote = false + + /** Prevents concurrent reconciliation from StartupSyncService and AccountViewModel */ + private val reconcileMutex = Mutex() + + @Volatile + private var pendingPushAfterSync = false + + /** + * Call after setting isSyncingFromRemote = false to push any changes + * that were made during reconciliation (e.g. repo removals). + */ + fun flushPendingSync() { + if (pendingPushAfterSync) { + pendingPushAfterSync = false + Log.d(TAG, "flushPendingSync: firing deferred push") + triggerRemoteSync() + } + } + + private var syncJob: kotlinx.coroutines.Job? = null + + private fun triggerRemoteSync() { + if (isSyncingFromRemote) { + Log.d(TAG, "triggerRemoteSync: skipped (syncing from remote), will push after sync") + pendingPushAfterSync = true + return + } + if (true /* !authManager.isAuthenticated */) { + Log.d(TAG, "triggerRemoteSync: skipped (not authenticated, simulated)") + return + } + Log.d(TAG, "triggerRemoteSync: scheduling push in 500ms") + syncJob?.cancel() + syncJob = syncScope.launch { + kotlinx.coroutines.delay(500) + } + } + + // Combined flow of enabled scrapers + val enabledScrapers: Flow> = combine( + scrapers, + pluginsEnabled + ) { scraperList, enabled -> + if (enabled) scraperList.filter { it.enabled } else emptyList() + } + + /** + * Add a new repository from manifest URL. + * Auto-detects format: tries NuvioTV manifest first, then external repo format. + */ + suspend fun addRepository(manifestUrl: String): Result = withContext(Dispatchers.IO) { + try { + // Resolve short codes (e.g. "cspr", "0094") via cutt.ly redirect + val resolvedUrl = if (isShortCode(manifestUrl)) { + Log.d(TAG, "Input looks like a short code: '$manifestUrl'") + resolveShortCode(manifestUrl.trim()) + ?: return@withContext Result.failure( + Exception("Failed to resolve short code: $manifestUrl") + ) + } else { + sanitizeScheme(manifestUrl).trimEnd('/') + } + + val sanitizedUrl = resolvedUrl.trimEnd('/') + val filename = sanitizedUrl.substringAfterLast("/") + val isExplicitJsonFile = filename.endsWith(".json", ignoreCase = true) + && !filename.equals("manifest.json", ignoreCase = true) + + val originalInput = manifestUrl.trim() + val fallbackName = if (isShortCode(originalInput)) originalInput else null + + // If the URL points to a specific .json file (not manifest.json), + // try external format first to avoid a wasted 404 on the NuvioTV path. + if (isExplicitJsonFile) { + Log.d(TAG, "URL ends in .json — trying external format first: $sanitizedUrl") + val externalResult = externalRepoParser.tryParse(sanitizedUrl, fallbackName) + if (externalResult != null) { + return@withContext addExternalRepository(sanitizedUrl, externalResult) + } + } + + // Try NuvioTV format (with canonicalized /manifest.json URL) + val canonicalManifestUrl = canonicalizeManifestUrl(sanitizedUrl) + Log.d(TAG, "Trying NuvioTV manifest: $canonicalManifestUrl") + + val manifest = fetchManifest(canonicalManifestUrl) + if (manifest != null) { + return@withContext addNuvioRepository(canonicalManifestUrl, manifest) + } + + // If we haven't tried external format yet, try it now + if (!isExplicitJsonFile) { + Log.d(TAG, "NuvioTV manifest not found, trying external format: $sanitizedUrl") + val externalResult = externalRepoParser.tryParse(sanitizedUrl, fallbackName) + if (externalResult != null) { + return@withContext addExternalRepository(sanitizedUrl, externalResult) + } + } + + Result.failure(Exception("Failed to parse repository: unrecognized format")) + } catch (e: Exception) { + Log.e(TAG, "Failed to add repository: ${e.message}", e) + Result.failure(e) + } + } + + /** + * Add a repository with a type hint from Supabase sync. + * Skips wrong detection paths when the type is already known, + * making reconciliation faster and more resilient to network issues. + */ + private suspend fun addRepositoryWithTypeHint( + manifestUrl: String, + typeHint: RepositoryType? + ): Result = withContext(Dispatchers.IO) { + try { + val sanitizedUrl = sanitizeScheme(manifestUrl).trimEnd('/') + + when (typeHint) { + RepositoryType.EXTERNAL_DEX -> { + Log.d(TAG, "addRepositoryWithTypeHint: EXTERNAL_DEX hint, trying external format: $sanitizedUrl") + val externalResult = externalRepoParser.tryParse(sanitizedUrl) + if (externalResult != null) { + return@withContext addExternalRepository(sanitizedUrl, externalResult) + } + // Hint was wrong or parse failed — fall through to auto-detect + Log.w(TAG, "addRepositoryWithTypeHint: EXTERNAL_DEX hint failed, falling back to auto-detect") + } + RepositoryType.NUVIO_JS -> { + Log.d(TAG, "addRepositoryWithTypeHint: NUVIO_JS hint, trying manifest: $sanitizedUrl") + val canonicalManifestUrl = canonicalizeManifestUrl(sanitizedUrl) + val manifest = fetchManifest(canonicalManifestUrl) + if (manifest != null) { + return@withContext addNuvioRepository(canonicalManifestUrl, manifest) + } + Log.w(TAG, "addRepositoryWithTypeHint: NUVIO_JS hint failed, falling back to auto-detect") + } + null -> { /* No hint — use auto-detect */ } + } + + // Fall back to full auto-detection + addRepository(sanitizedUrl) + } catch (e: Exception) { + Log.e(TAG, "Failed to add repository with hint: ${e.message}", e) + Result.failure(e) + } + } + + private suspend fun addNuvioRepository( + canonicalManifestUrl: String, + manifest: PluginManifest + ): Result { + val repo = PluginRepository( + id = UUID.randomUUID().toString(), + name = manifest.name, + url = canonicalManifestUrl, + enabled = true, + lastUpdated = System.currentTimeMillis(), + scraperCount = manifest.getActiveScrapers().size, + type = RepositoryType.NUVIO_JS + ) + + dataStore.addRepository(repo) + downloadJsScrapers(repo.id, canonicalManifestUrl, manifest.getActiveScrapers()) + + Log.d(TAG, "NuvioTV repository added: ${repo.name} with ${manifest.getActiveScrapers().size} scrapers") + triggerRemoteSync() + return Result.success(repo) + } + + private suspend fun addExternalRepository( + repoUrl: String, + parseResult: com.arflix.tv.core.plugin.cloudstream.ExternalRepoParseResult + ): Result { + // Prevent duplicate repos by URL + val existingRepo = dataStore.repositories.first() + .find { normalizeUrl(it.url) == normalizeUrl(repoUrl) } + if (existingRepo != null) { + Log.d(TAG, "External repository already exists: ${existingRepo.name} (${existingRepo.url})") + return Result.success(existingRepo) + } + + val repo = PluginRepository( + id = UUID.randomUUID().toString(), + name = parseResult.name, + url = repoUrl, + description = parseResult.description, + enabled = true, + lastUpdated = System.currentTimeMillis(), + scraperCount = parseResult.plugins.size, + type = RepositoryType.EXTERNAL_DEX + ) + + dataStore.addRepository(repo) + downloadDexExtensions(repo.id, parseResult.plugins) + + Log.d(TAG, "External repository added: ${repo.name} with ${parseResult.plugins.size} extensions") + triggerRemoteSync() + return Result.success(repo) + } + + /** + * Remove a repository and its scrapers + */ + suspend fun removeRepository(repoId: String) { + val scraperList = dataStore.scrapers.first() + val repo = dataStore.repositories.first().find { it.id == repoId } + + // Remove all scrapers from this repo + scraperList.filter { it.repositoryId == repoId }.forEach { scraper -> + if (scraper.type == RepositoryType.EXTERNAL_DEX || repo?.type == RepositoryType.EXTERNAL_DEX) { + externalExtensionLoader.deleteExtension(scraper.id) + } else { + dataStore.deleteScraperCode(scraper.id) + } + } + + // Remove scrapers from list + val updatedScrapers = scraperList.filter { it.repositoryId != repoId } + dataStore.saveScrapers(updatedScrapers) + + // Remove repository + dataStore.removeRepository(repoId) + + // Push synchronously when user-initiated (not during reconciliation) + // to prevent the next sync pull from re-adding the removed repo + if (!isSyncingFromRemote && false /* authManager.isAuthenticated */) { + Log.d(TAG, "removeRepository: pushing removal to remote synchronously") + // pluginSyncService.pushToRemote() + } else if (isSyncingFromRemote) { + pendingPushAfterSync = true + } + } + + + /** + * Reconcile local plugin repos with the remote list from Supabase. + * @param remotePlugins list of remote plugin info (URL + optional type hint) + * @param removeMissingLocal if true, remove local repos not in the remote list + */ + suspend fun reconcileWithRemoteRepoUrls( + remotePlugins: List, + removeMissingLocal: Boolean = true + ) = reconcileMutex.withLock { + val normalizedRemote = remotePlugins + .map { it.copy(url = canonicalizeRepoUrl(it.url)) } + .filter { it.url.isNotEmpty() } + .distinctBy { normalizeUrl(it.url) } + val remoteUrlSet = normalizedRemote.map { normalizeUrl(it.url) }.toSet() + + val initialLocalRepos = dataStore.repositories.first() + val initialLocalByNormalizedUrl = initialLocalRepos.associateBy { normalizeUrl(it.url) } + val shouldRemoveMissingLocal = if (removeMissingLocal && normalizedRemote.isEmpty() && initialLocalRepos.isNotEmpty()) { + Log.w( + TAG, + "reconcileWithRemoteRepoUrls: remote list empty while local has ${initialLocalRepos.size} repos; preserving local plugins" + ) + false + } else { + removeMissingLocal + } + + if (shouldRemoveMissingLocal) { + initialLocalRepos + .filter { normalizeUrl(it.url) !in remoteUrlSet } + .forEach { repo -> + Log.d(TAG, "reconcile: removing local repo not in remote: ${repo.name} (${repo.url})") + removeRepository(repo.id) + } + } + + normalizedRemote.forEach { remotePlugin -> + if (initialLocalByNormalizedUrl[normalizeUrl(remotePlugin.url)] == null) { + val typeHint = remotePlugin.repoType?.let { + try { RepositoryType.valueOf(it) } catch (_: Exception) { null } + } + val result = addRepositoryWithTypeHint(remotePlugin.url, typeHint) + if (result.isFailure) { + Log.e(TAG, "reconcile: failed to add repo ${remotePlugin.url}: ${result.exceptionOrNull()?.message}") + } + } + } + + val currentRepos = dataStore.repositories.first() + val currentByNormalizedUrl = currentRepos.associateBy { normalizeUrl(it.url) } + val remoteOrderedRepos = normalizedRemote + .mapNotNull { currentByNormalizedUrl[normalizeUrl(it.url)] } + val extras = currentRepos + .filter { normalizeUrl(it.url) !in remoteUrlSet } + + val reordered = if (shouldRemoveMissingLocal) remoteOrderedRepos else remoteOrderedRepos + extras + if (reordered.map { it.id } != currentRepos.map { it.id }) { + dataStore.saveRepositories(reordered) + } + } + + /** Convenience overload for plain URL lists (no type hints) */ + @JvmName("reconcileWithRemoteRepoUrlStrings") + suspend fun reconcileWithRemoteRepoUrls( + remoteUrls: List, + removeMissingLocal: Boolean = true + ) { + reconcileWithRemoteRepoUrls( + remotePlugins = remoteUrls.map { RemotePluginInfo(url = it) }, + removeMissingLocal = removeMissingLocal + ) + } + + /** + * Refresh a repository - re-download manifest and scrapers + */ + suspend fun refreshRepository(repoId: String): Result = withContext(Dispatchers.IO) { + try { + val repo = dataStore.repositories.first().find { it.id == repoId } + ?: return@withContext Result.failure(Exception("Repository not found")) + + if (repo.type == RepositoryType.EXTERNAL_DEX) { + return@withContext refreshExternalRepository(repo) + } + + val manifest = fetchManifest(repo.url) + ?: return@withContext Result.failure(Exception("Failed to fetch manifest")) + + // Update repository + val updatedRepo = repo.copy( + name = manifest.name, + lastUpdated = System.currentTimeMillis(), + scraperCount = manifest.getActiveScrapers().size + ) + dataStore.updateRepository(updatedRepo) + + // Re-download scrapers + downloadJsScrapers(repo.id, repo.url, manifest.getActiveScrapers()) + + Result.success(Unit) + } catch (e: Exception) { + Log.e(TAG, "Failed to refresh repository: ${e.message}", e) + Result.failure(e) + } + } + + private suspend fun refreshExternalRepository(repo: PluginRepository): Result { + val parseResult = externalRepoParser.tryParse(repo.url) + ?: return Result.failure(Exception("Failed to parse external repository")) + + // Evict stale class loaders for old scrapers + val oldScrapers = dataStore.scrapers.first().filter { it.repositoryId == repo.id } + oldScrapers.forEach { externalExtensionLoader.evictCache(it.id) } + + val updatedRepo = repo.copy( + name = parseResult.name, + lastUpdated = System.currentTimeMillis(), + scraperCount = parseResult.plugins.size + ) + dataStore.updateRepository(updatedRepo) + + downloadDexExtensions(repo.id, parseResult.plugins) + + return Result.success(Unit) + } + + /** + * Toggle scraper enabled state + */ + suspend fun toggleScraper(scraperId: String, enabled: Boolean) { + val scraperList = dataStore.scrapers.first() + val updatedScrapers = scraperList.map { scraper -> + if (scraper.id == scraperId) scraper.copy(enabled = enabled) else scraper + } + dataStore.saveScrapers(updatedScrapers) + } + + /** + * Toggle all scrapers belonging to a repository + */ + suspend fun toggleAllScrapersForRepo(repoId: String, enabled: Boolean) { + val scraperList = dataStore.scrapers.first() + val updatedScrapers = scraperList.map { scraper -> + if (scraper.repositoryId == repoId) scraper.copy(enabled = enabled) else scraper + } + dataStore.saveScrapers(updatedScrapers) + } + + /** + * Toggle plugins globally enabled + */ + suspend fun setPluginsEnabled(enabled: Boolean) { + dataStore.setPluginsEnabled(enabled) + } + + suspend fun setGroupStreamsByRepository(enabled: Boolean) { + dataStore.setGroupStreamsByRepository(enabled) + } + + /** + * Execute all enabled scrapers for a given media + */ + suspend fun executeScrapers( + tmdbId: String, + mediaType: String, + season: Int? = null, + episode: Int? = null + ): List = coroutineScope { + if (!dataStore.pluginsEnabled.first()) { + return@coroutineScope emptyList() + } + + val enabledScraperList = enabledScrapers.first() + .filter { it.supportsType(mediaType) } + + if (enabledScraperList.isEmpty()) { + return@coroutineScope emptyList() + } + + Log.d(TAG, "Executing ${enabledScraperList.size} scrapers for $mediaType:$tmdbId") + + // Preload all extractors from EXTERNAL_DEX repos before any scraper runs + val dexScraperIds = enabledScraperList + .filter { it.type == RepositoryType.EXTERNAL_DEX } + .map { it.id } + if (dexScraperIds.isNotEmpty()) { + // Also load ALL dex scrapers from the same repos (not just enabled ones) + // since extractors can live in any .cs3 file + val allDexIds = dataStore.scrapers.first() + .filter { it.type == RepositoryType.EXTERNAL_DEX } + .map { it.id } + externalExtensionLoader.ensureExtractorsLoaded(allDexIds) + } + + val results = enabledScraperList.map { scraper -> + async { + executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode) + } + }.awaitAll() + + results.flatten() + .distinctBy { it.url } + .take(MAX_RESULT_ITEMS) + } + + /** + * Execute all enabled scrapers and emit results as each scraper completes. + * Returns a Flow that emits (scraperName, results) pairs. + */ + fun executeScrapersStreaming( + tmdbId: String, + mediaType: String, + season: Int? = null, + episode: Int? = null + ): Flow>> = channelFlow { + val enabledList = enabledScrapers.first() + .filter { it.supportsType(mediaType) } + + if (enabledList.isEmpty() || !dataStore.pluginsEnabled.first()) { + return@channelFlow + } + + Log.d(TAG, "Streaming execution of ${enabledList.size} scrapers for $mediaType:$tmdbId") + + // Preload all extractors from EXTERNAL_DEX repos before any scraper runs + val dexScraperIds = enabledList.filter { it.type == RepositoryType.EXTERNAL_DEX }.map { it.id } + if (dexScraperIds.isNotEmpty()) { + val allDexIds = dataStore.scrapers.first() + .filter { it.type == RepositoryType.EXTERNAL_DEX } + .map { it.id } + externalExtensionLoader.ensureExtractorsLoaded(allDexIds) + } + + // Launch all scrapers concurrently within the channelFlow scope + enabledList.forEach { scraper -> + launch { + try { + val results = executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode) + send(scraper to results) + } catch (e: Exception) { + Log.e(TAG, "Scraper ${scraper.name} failed in streaming: ${e.message}") + send(scraper to emptyList()) + } + } + } + } + + /** + * Execute a single scraper with single-flight deduplication + */ + private suspend fun executeScraperWithSingleFlight( + scraper: ScraperInfo, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List { + val cacheKey = "${scraper.id}:$tmdbId:$mediaType:$season:$episode" + + // Check if already in flight + val existing = inFlightScrapers[cacheKey] + if (existing != null) { + return try { + existing.await() + } catch (e: Exception) { + emptyList() + } + } + + // Create new deferred + return coroutineScope { + val deferred = async { + scraperSemaphore.withPermit { + executeScraper(scraper, tmdbId, mediaType, season, episode) + } + } + + inFlightScrapers[cacheKey] = deferred + + try { + deferred.await() + } catch (e: Exception) { + Log.e(TAG, "Scraper ${scraper.name} failed: ${e.message}") + emptyList() + } finally { + inFlightScrapers.remove(cacheKey) + } + } + } + + /** + * Execute a single scraper, dispatching by type. + */ + suspend fun executeScraper( + scraper: ScraperInfo, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List { + return when (scraper.type) { + RepositoryType.EXTERNAL_DEX -> executeExternalDexScraper(scraper, tmdbId, mediaType, season, episode) + RepositoryType.NUVIO_JS -> executeJsScraper(scraper, tmdbId, mediaType, season, episode) + } + } + + private suspend fun executeJsScraper( + scraper: ScraperInfo, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List { + return try { + val code = dataStore.getScraperCode(scraper.id) + if (code.isNullOrBlank()) { + Log.w(TAG, "No code found for scraper: ${scraper.name}") + return emptyList() + } + + // Debug: confirm which exact JS code is running on-device. + try { + val sha = sha256Hex(code) + val bytes = code.toByteArray(Charsets.UTF_8).size + val hasHrefliLogs = code.contains("[UHDMovies][Hrefli]", ignoreCase = false) || + code.contains("[Hrefli]", ignoreCase = false) + Log.d( + TAG, + "Scraper code loaded: ${scraper.name}(${scraper.id}) bytes=$bytes sha256=${sha.take(12)} hrefliLogs=$hasHrefliLogs" + ) + } catch (_: Exception) { + // ignore + } + + val settings = dataStore.getScraperSettings(scraper.id) + + Log.d(TAG, "Executing scraper: ${scraper.name}") + val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) { + // Run plugin JS on the dedicated low-priority pool so a buggy + // scraper can't burn cores at the expense of ExoPlayer / UI. + withContext(pluginDispatcher) { + runtime.executePlugin( + code = code, + tmdbId = tmdbId, + mediaType = mediaType, + season = season, + episode = episode, + scraperId = scraper.id, + scraperSettings = settings + ) + } + } + + if (results == null) { + Log.w(TAG, "Scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms") + return emptyList() + } + + Log.d(TAG, "Scraper ${scraper.name} returned ${results.size} results") + results.map { it.copy(provider = scraper.name) } + + } catch (e: Exception) { + Log.e(TAG, "Failed to execute scraper ${scraper.name}: ${e.message}", e) + emptyList() + } + } + + private suspend fun executeExternalDexScraper( + scraper: ScraperInfo, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List { + return try { + Log.d(TAG, "Executing DEX scraper: ${scraper.name}") + val results = withTimeoutOrNull(SCRAPER_TIMEOUT_MS) { + // DEX (.cs3) scrapers run arbitrary Kotlin from external repos. + // Wrap on the low-priority pool for the same reason as the JS + // path: keep their CPU footprint out of ExoPlayer's way. + withContext(pluginDispatcher) { + externalExtensionRunner.execute(scraper.id, tmdbId, mediaType, season, episode) + } + } + if (results == null) { + Log.w(TAG, "DEX scraper ${scraper.name} timed out after ${SCRAPER_TIMEOUT_MS}ms") + return emptyList() + } + Log.d(TAG, "DEX scraper ${scraper.name} returned ${results.size} results") + results.map { it.copy(provider = scraper.name) } + } catch (e: Exception) { + Log.e(TAG, "Failed to execute DEX scraper ${scraper.name}: ${e.message}", e) + emptyList() + } + } + + /** + * Test a scraper with sample data, returning results along with diagnostic steps. + */ + suspend fun testScraper(scraperId: String): Result, TestDiagnostics>> { + val diagnostics = TestDiagnostics() + val scraper = dataStore.scrapers.first().find { it.id == scraperId } + if (scraper == null) { + diagnostics.addStep("Scraper '$scraperId' not found in datastore") + return Result.failure(Exception("Scraper not found")) + } + + diagnostics.addStep("Scraper: ${scraper.name} (type=${scraper.type})") + + // Use a popular movie for testing (The Matrix - 603) + val testTmdbId = "603" + val testMediaType = if (scraper.supportsType("movie")) "movie" else "series" + diagnostics.addStep("Test: TMDB $testTmdbId ($testMediaType)") + + // Preload extractors from ALL .cs3 files in the same repo(s) + if (scraper.type == RepositoryType.EXTERNAL_DEX) { + val allDexIds = dataStore.scrapers.first() + .filter { it.type == RepositoryType.EXTERNAL_DEX } + .map { it.id } + externalExtensionLoader.ensureExtractorsLoaded(allDexIds, diagnostics) + } + + val testSeason = if (testMediaType == "movie") null else 1 + val testEpisode = if (testMediaType == "movie") null else 1 + + return try { + val results = when (scraper.type) { + RepositoryType.EXTERNAL_DEX -> { + externalExtensionRunner.executeWithDiagnostics( + scraper.id, testTmdbId, testMediaType, testSeason, testEpisode, diagnostics + ) + } + RepositoryType.NUVIO_JS -> { + diagnostics.addStep("Executing JS scraper...") + executeScraper(scraper, testTmdbId, testMediaType, testSeason, testEpisode) + } + } + diagnostics.addStep("Result: ${results.size} streams") + Result.success(results to diagnostics) + } catch (e: Exception) { + diagnostics.addStep("Exception: ${e.javaClass.simpleName}: ${e.message}") + Result.success(emptyList() to diagnostics) + } + } + + private suspend fun fetchManifest(url: String): PluginManifest? = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url(url) + .header("User-Agent", "NuvioTV/1.0") + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.e(TAG, "Failed to fetch manifest: ${response.code}") + return@withContext null + } + + val body = response.body?.string() ?: return@withContext null + manifestAdapter.fromJson(body) + } + + } catch (e: Exception) { + Log.e(TAG, "Error fetching manifest: ${e.message}", e) + null + } + } + + private suspend fun downloadJsScrapers( + repoId: String, + manifestUrl: String, + scraperInfos: List + ) = withContext(Dispatchers.IO) { + val baseUrl = manifestUrl.substringBeforeLast("/") + val existingScrapers = dataStore.scrapers.first().toMutableList() + + scraperInfos.forEach { info -> + try { + val codeUrl = if (info.filename.startsWith("http")) { + info.filename + } else { + "$baseUrl/${info.filename}" + } + + // Check response size before downloading + val headRequest = Request.Builder() + .url(codeUrl) + .head() + .build() + + val contentLength = httpClient.newCall(headRequest).execute().use { headResponse -> + headResponse.header("Content-Length")?.toLongOrNull() ?: 0 + } + + if (contentLength > MAX_RESPONSE_SIZE) { + Log.w(TAG, "Scraper ${info.name} too large: $contentLength bytes") + return@forEach + } + + // Download code + val codeRequest = Request.Builder() + .url(codeUrl) + .header("User-Agent", "NuvioTV/1.0") + .build() + + val code = httpClient.newCall(codeRequest).execute().use { codeResponse -> + if (!codeResponse.isSuccessful) { + Log.e(TAG, "Failed to download scraper ${info.name}: ${codeResponse.code}") + return@forEach + } + + codeResponse.body?.string() ?: return@forEach + } + + try { + val sha = sha256Hex(code) + val hasHrefliLogs = code.contains("[UHDMovies][Hrefli]", ignoreCase = false) || + code.contains("[Hrefli]", ignoreCase = false) + Log.d( + TAG, + "Downloaded scraper code: ${info.name}(${info.id}) bytes=${code.toByteArray(Charsets.UTF_8).size} sha256=${sha.take(12)} hrefliLogs=$hasHrefliLogs url=$codeUrl" + ) + } catch (_: Exception) { + // ignore + } + + // Create scraper info + val scraperId = "$repoId:${info.id}" + val existingScraper = existingScrapers.firstOrNull { it.id == scraperId } + val isSafe = PluginSafety.isSafeToLoad( + pluginName = info.name, + pluginPackage = scraperId, + filename = info.filename + ) + + if (!isSafe) { + Log.w(TAG, "Skipping unsafe scraper: ${info.name}") + return@forEach + } + + val defaultEnabled = info.enabled + val scraper = ScraperInfo( + id = scraperId, + repositoryId = repoId, + name = info.name, + description = info.description ?: "", + version = info.version, + filename = info.filename, + supportedTypes = info.supportedTypes, + enabled = existingScraper?.enabled ?: defaultEnabled, + manifestEnabled = info.enabled, + logo = info.logo, + contentLanguage = info.contentLanguage ?: emptyList(), + formats = info.formats + ) + + // Save code + dataStore.saveScraperCode(scraperId, code) + + // Update scraper list + existingScrapers.removeAll { it.id == scraperId } + existingScrapers.add(scraper) + + Log.d(TAG, "Downloaded scraper: ${info.name}") + + } catch (e: Exception) { + Log.e(TAG, "Error downloading scraper ${info.name}: ${e.message}", e) + } + } + + dataStore.saveScrapers(existingScrapers) + } + + /** + * Download .cs3 DEX extensions in parallel and register them as scrapers. + * Uses a semaphore to limit concurrent downloads and avoid overwhelming + * the network. Scrapers are saved incrementally in batches. + */ + private suspend fun downloadDexExtensions( + repoId: String, + plugins: List + ) = withContext(Dispatchers.IO) { + val existingScrapers = dataStore.scrapers.first().toMutableList() + val downloadSemaphore = Semaphore(MAX_PARALLEL_DOWNLOADS) + val newScrapers = java.util.Collections.synchronizedList(mutableListOf()) + + // Download all extensions in parallel with limited concurrency + val jobs = plugins.map { plugin -> + async { + downloadSemaphore.withPermit { + try { + val scraperId = "$repoId:${plugin.internalName}" + + val file = externalExtensionLoader.downloadExtension(scraperId, plugin.url) + if (file == null) { + Log.e(TAG, "Failed to download extension: ${plugin.name}") + return@withPermit + } + + val supportedTypes = plugin.tvTypes + ?.mapNotNull { tvTypeFromString(it) } + ?.map { it.toNuvioType() } + ?.distinct() + ?.ifEmpty { listOf("movie", "tv") } + ?: listOf("movie", "tv") + + val scraper = ScraperInfo( + id = scraperId, + repositoryId = repoId, + name = plugin.name, + description = plugin.description ?: "", + version = plugin.version.toString(), + filename = plugin.url, + supportedTypes = supportedTypes, + enabled = true, + manifestEnabled = plugin.status == 1, + logo = plugin.iconUrl, + contentLanguage = emptyList(), + formats = null, + type = RepositoryType.EXTERNAL_DEX + ) + + newScrapers.add(scraper) + Log.d(TAG, "Downloaded DEX extension: ${plugin.name} (${file.length()} bytes)") + } catch (e: Exception) { + Log.e(TAG, "Error downloading extension ${plugin.name}: ${e.message}", e) + } + } + } + } + + jobs.awaitAll() + + // Merge new scrapers into existing list + val newScraperIds = newScrapers.map { it.id }.toSet() + existingScrapers.removeAll { it.id in newScraperIds } + existingScrapers.addAll(newScrapers) + dataStore.saveScrapers(existingScrapers) + + Log.d(TAG, "Downloaded ${newScrapers.size}/${plugins.size} extensions for repo $repoId") + } + + companion object { + private const val MAX_PARALLEL_DOWNLOADS = 10 + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginRuntime.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginRuntime.kt new file mode 100644 index 00000000..aec04abb --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginRuntime.kt @@ -0,0 +1,1322 @@ +package com.arflix.tv.core.plugin + +import android.util.Log +import com.dokar.quickjs.binding.define +import com.dokar.quickjs.binding.function +import com.dokar.quickjs.binding.asyncFunction +import com.dokar.quickjs.quickJs +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.arflix.tv.BuildConfig +import com.arflix.tv.domain.model.LocalScraperResult +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.ContinuationInterceptor +import kotlin.coroutines.coroutineContext +import okhttp3.Call +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.select.Elements +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.URL +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.zip.GZIPInputStream +import java.util.zip.InflaterInputStream +import kotlin.text.Charsets +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "PluginRuntime" +private const val PLUGIN_TIMEOUT_MS = 60_000L +private const val MAX_FETCH_RESPONSE_BYTES = 256 * 1024 +private const val MAX_FETCH_BODY_CHARS = 256 * 1024 +private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 +private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" + +@Singleton +class PluginRuntime @Inject constructor() { + + private val gson: Gson = GsonBuilder().create() + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .proxy(java.net.Proxy.NO_PROXY) + .build() + + // Pre-compiled regex for :contains() selector conversion + private val containsRegex = Regex(""":contains\(["']([^"']+)["']\)""") + + @Volatile + private var cachedCryptoJsSource: String? = null + + private fun loadCryptoJsSourceOrNull(): String? { + cachedCryptoJsSource?.let { return it } + val cl = this::class.java.classLoader ?: return null + + // WebJars layout: META-INF/resources/webjars/crypto-js//... + val candidatePaths = listOf( + "META-INF/resources/webjars/crypto-js/4.2.0/crypto-js.min.js", + "META-INF/resources/webjars/crypto-js/4.2.0/crypto-js.js", + "META-INF/resources/webjars/crypto-js/4.2.0/crypto-js/crypto-js.min.js", + "META-INF/resources/webjars/crypto-js/4.2.0/crypto-js/crypto-js.js", + ) + + for (path in candidatePaths) { + try { + cl.getResourceAsStream(path)?.use { input -> + val text = input.readBytes().toString(Charsets.UTF_8) + cachedCryptoJsSource = text + return text + } + } catch (_: Exception) { + // Try next candidate + } + } + return null + } + + private fun normalizeBase64(input: String): String { + var s = input.trim().replace("\n", "").replace("\r", "").replace(" ", "") + s = s.replace('-', '+').replace('_', '/') + val mod = s.length % 4 + if (mod != 0) { + s += "=".repeat(4 - mod) + } + return s + } + + private fun base64Decode(input: String): ByteArray { + return Base64.getDecoder().decode(normalizeBase64(input)) + } + + private fun base64Encode(bytes: ByteArray): String { + return Base64.getEncoder().encodeToString(bytes) + } + + private fun bytesToHex(bytes: ByteArray): String { + val sb = StringBuilder(bytes.size * 2) + for (b in bytes) { + sb.append(((b.toInt() shr 4) and 0xF).toString(16)) + sb.append((b.toInt() and 0xF).toString(16)) + } + return sb.toString() + } + + /** + * Execute a plugin and return streams. + * + * Note: this function intentionally does **not** wrap with + * `withContext(Dispatchers.IO)`. The caller (`PluginManager`) supplies a + * dedicated low-priority dispatcher (`pluginDispatcher`) so plugin CPU + * work can't preempt ExoPlayer / UI threads. Forcing `Dispatchers.IO` + * here would undo that isolation. + */ + suspend fun executePlugin( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map = emptyMap() + ): List = withTimeout(PLUGIN_TIMEOUT_MS) { + executePluginInternal(code, tmdbId, mediaType, season, episode, scraperId, scraperSettings) + } + + private suspend fun executePluginInternal( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map + ): List { + val documentCache = ConcurrentHashMap() + val elementCache = ConcurrentHashMap() + val inFlightCalls = ConcurrentHashMap.newKeySet() + + var resultJson = "[]" + val resultChannel = kotlinx.coroutines.channels.Channel(1) + + // Inherit the caller's dispatcher (the low-priority + // pluginDispatcher set up by PluginManager) instead of hard-coding + // Dispatchers.IO, so QuickJS interpretation runs at MIN_PRIORITY too. + // ContinuationInterceptor is the context key kotlinx-coroutines uses + // to store the active CoroutineDispatcher. + val parentDispatcher: CoroutineDispatcher = + (coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher) ?: Dispatchers.IO + + try { + quickJs(parentDispatcher) { + // Define console object - must return null to avoid quickjs conversion issues + define("console") { + function("log") { args -> + Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + null + } + function("error") { args -> + Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + null + } + function("warn") { args -> + Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + null + } + function("info") { args -> + Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + null + } + function("debug") { args -> + Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + null + } + } + + asyncFunction("__native_fetch") { args -> + val url = args.getOrNull(0)?.toString() ?: "" + val method = args.getOrNull(1)?.toString() ?: "GET" + val headersJson = args.getOrNull(2)?.toString() ?: "{}" + val body = args.getOrNull(3)?.toString() ?: "" + try { + performNativeFetch(url, method, headersJson, body, inFlightCalls) + } catch (t: Throwable) { + Log.e(TAG, "Async fetch bridge error for $method $url: ${t.message}") + gson.toJson( + mapOf( + "ok" to false, + "status" to 0, + "statusText" to (t.message ?: "Fetch failed"), + "url" to url, + "body" to "", + "headers" to emptyMap() + ) + ) + } + } + + // Define URL parser + function("__parse_url") { args -> + val urlString = args.getOrNull(0)?.toString() ?: "" + parseUrl(urlString) + } + + // Define cheerio load function + function("__cheerio_load") { args -> + val html = args.getOrNull(0)?.toString() ?: "" + val docId = UUID.randomUUID().toString() + val doc = Jsoup.parse(html) + documentCache[docId] = doc + docId + } + + // Define cheerio select function + function("__cheerio_select") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + var selector = args.getOrNull(1)?.toString() ?: "" + val doc = documentCache[docId] ?: return@function "[]" + try { + // Convert cheerio :contains("text") to jsoup :contains(text) + selector = selector.replace(containsRegex, ":contains($1)") + val elements = if (selector.isEmpty()) { + Elements() + } else { + doc.select(selector) + } + val ids = elements.mapIndexed { index, el -> + val elId = "$docId:$index:${el.hashCode()}" + elementCache[elId] = el + elId + } + // Use simple JSON array construction to avoid Gson issues + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (e: Exception) { + "[]" + } + } + + // Define cheerio find function + function("__cheerio_find") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + var selector = args.getOrNull(2)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "[]" + try { + // Convert cheerio :contains("text") to jsoup :contains(text) + selector = selector.replace(containsRegex, ":contains($1)") + val elements = element.select(selector) + val ids = elements.mapIndexed { index, el -> + val elId = "$docId:find:$index:${el.hashCode()}" + elementCache[elId] = el + elId + } + // Use simple JSON array construction to avoid Gson issues + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (e: Exception) { + "[]" + } + } + + // Define cheerio text function + function("__cheerio_text") { args -> + val elementIds = args.getOrNull(1)?.toString() ?: "" + val ids = elementIds.split(",").filter { it.isNotEmpty() } + val texts = ids.mapNotNull { id -> + elementCache[id]?.text() + } + texts.joinToString(" ") + } + + // Define cheerio html function + function("__cheerio_html") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + if (elementId.isEmpty()) { + documentCache[docId]?.html() ?: "" + } else { + elementCache[elementId]?.html() ?: "" + } + } + + // Define cheerio inner html function + function("__cheerio_inner_html") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + elementCache[elementId]?.html() ?: "" + } + + // Define cheerio attr function + function("__cheerio_attr") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + val attrName = args.getOrNull(2)?.toString() ?: "" + val value = elementCache[elementId]?.attr(attrName) + if (value.isNullOrEmpty()) "__UNDEFINED__" else value + } + + // Define cheerio next function + function("__cheerio_next") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val el = elementCache[elementId] ?: return@function "__NONE__" + val next = el.nextElementSibling() ?: return@function "__NONE__" + val nextId = "$docId:next:${next.hashCode()}" + elementCache[nextId] = next + nextId + } + + // Define cheerio prev function + function("__cheerio_prev") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val el = elementCache[elementId] ?: return@function "__NONE__" + val prev = el.previousElementSibling() ?: return@function "__NONE__" + val prevId = "$docId:prev:${prev.hashCode()}" + elementCache[prevId] = prev + prevId + } + + // Note: crypto-js is now loaded as a real library (WebJars) before plugin execution. + + // Function to capture results - must return null to avoid quickjs conversion issues + function("__capture_result") { args -> + val result = args.getOrNull(0)?.toString() ?: "[]" + resultChannel.trySend(result) + null + } + + // Inject JavaScript polyfills + val settingsJson = gson.toJson(scraperSettings) + val polyfillCode = buildPolyfillCode(scraperId, settingsJson) + evaluate(polyfillCode) + + // Load real crypto-js into the JS runtime before plugin code runs. + loadCryptoJsSourceOrNull()?.let { cryptoJsSource -> + evaluate(cryptoJsSource) + } + + // Execute plugin code with module wrapper - wrapped in IIFE to avoid + // redeclaration conflicts with polyfill vars (e.g. cheerio, URL, fetch). + // Must NOT pass polyfill names as parameters, because plugins use + // 'const cheerio = require(...)' which would conflict with a parameter named 'cheerio'. + val wrappedCode = """ + var module = { exports: {} }; + var exports = module.exports; + (function() { + $code + })(); + """.trimIndent() + evaluate(wrappedCode) + + // Call getStreams and capture result + val seasonArg = season?.toString() ?: "undefined" + val episodeArg = episode?.toString() ?: "undefined" + + val callCode = """ + (async function() { + try { + var getStreams = module.exports.getStreams || globalThis.getStreams; + if (!getStreams) { + console.error("getStreams function not found on module.exports or globalThis"); + __capture_result(JSON.stringify([])); + return; + } + console.log("Calling getStreams with tmdbId=$tmdbId type=$mediaType s=$seasonArg e=$episodeArg"); + var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); + console.log("getStreams returned: " + (result ? result.length : 0) + " streams"); + __capture_result(JSON.stringify(result || [])); + } catch (e) { + console.error("getStreams error:", e.message || e, e.stack || ""); + __capture_result(JSON.stringify([])); + } + })(); + """.trimIndent() + + evaluate(callCode) + + // Wait for the JS async execution to finish and capture the result + resultJson = resultChannel.receive() + } + + return parseJsonResults(resultJson) + + } catch (e: Exception) { + Log.e(TAG, "Plugin execution failed: ${e.message}", e) + throw e + } finally { + // Clean up caches + documentCache.clear() + elementCache.clear() + // Cancel any network calls still in progress when plugin execution exits. + inFlightCalls.forEach { call -> call.cancel() } + inFlightCalls.clear() + } + } + + private suspend fun performNativeFetch( + url: String, + method: String, + headersJson: String, + body: String, + inFlightCalls: MutableSet + ): String = kotlinx.coroutines.suspendCancellableCoroutine { continuation -> + Log.d(TAG, "Fetch: $method $url body=${body.take(200)}") + try { + val headers = mutableMapOf() + try { + val headersMap = gson.fromJson(headersJson, Map::class.java) + headersMap?.forEach { (k, v) -> + if (k != null && v != null) { + val key = k.toString() + if (!key.equals("Accept-Encoding", ignoreCase = true)) { + headers[key] = v.toString() + } + } + } + } catch (e: Exception) { + // Ignore header parsing errors + } + + if (!headers.containsKey("User-Agent")) { + headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + val requestBuilder = Request.Builder() + .url(url) + .headers(Headers.headersOf(*headers.flatMap { listOf(it.key, it.value) }.toTypedArray())) + + when (method.uppercase()) { + "POST" -> { + val contentType = headers["Content-Type"] ?: "application/x-www-form-urlencoded" + requestBuilder.post(body.toByteArray(Charsets.UTF_8).toRequestBody(contentType.toMediaType())) + } + "PUT" -> { + val contentType = headers["Content-Type"] ?: "application/json" + requestBuilder.put(body.toByteArray(Charsets.UTF_8).toRequestBody(contentType.toMediaType())) + } + "DELETE" -> requestBuilder.delete() + else -> requestBuilder.get() + } + + val request = requestBuilder.build() + val call = httpClient.newCall(request) + inFlightCalls.add(call) + + continuation.invokeOnCancellation { + call.cancel() + inFlightCalls.remove(call) + } + + call.enqueue(object : okhttp3.Callback { + override fun onFailure(call: Call, e: java.io.IOException) { + inFlightCalls.remove(call) + if (continuation.isActive) { + val result = gson.toJson(mapOf( + "ok" to false, + "status" to 0, + "statusText" to (e.message ?: "Fetch failed"), + "url" to url, + "body" to "", + "headers" to emptyMap() + )) + continuation.resumeWith(Result.success(result)) + } + } + + override fun onResponse(call: Call, httpResponse: okhttp3.Response) { + inFlightCalls.remove(call) + if (!continuation.isActive) { + httpResponse.close() + return + } + + try { + httpResponse.use { + val bodyContentType = it.body?.contentType() + val contentEncoding = it.header("Content-Encoding")?.lowercase()?.trim() + val decodedRead = try { + val stream = it.body?.byteStream() + if (stream == null) { + BoundedReadResult(ByteArray(0), false) + } else { + val decodeStream: InputStream = when (contentEncoding) { + "gzip" -> java.util.zip.GZIPInputStream(stream) + "deflate" -> java.util.zip.InflaterInputStream(stream) + else -> stream + } + decodeStream.use { decoded -> + readAtMostBytes(decoded, MAX_FETCH_RESPONSE_BYTES) + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to read/decode response body for $url: ${e.message}") + BoundedReadResult(ByteArray(0), false) + } + + val charset = bodyContentType?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + val responseBody = decodeBodyToSafeString(decodedRead.bytes, charset) + val responseHeaders = mutableMapOf() + it.headers.forEach { (name, value) -> + responseHeaders[name.lowercase()] = truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) + } + + val result = mapOf( + "ok" to it.isSuccessful, + "status" to it.code, + "statusText" to it.message, + "url" to it.request.url.toString(), + "body" to responseBody, + "headers" to responseHeaders, + "truncated" to decodedRead.truncated + ) + + Log.d(TAG, "Fetch result: ${it.code} ${it.message} url=$url bodyLen=${responseBody.length} bodyPreview=${responseBody.take(300)}") + continuation.resumeWith(Result.success(gson.toJson(result))) + } + } catch (e: Exception) { + Log.e(TAG, "Fetch parsing error: ${e.message}") + val result = gson.toJson(mapOf( + "ok" to false, + "status" to 0, + "statusText" to (e.message ?: "Fetch failed"), + "url" to url, + "body" to "", + "headers" to emptyMap() + )) + continuation.resumeWith(Result.success(result)) + } + } + }) + } catch (e: Exception) { + Log.e(TAG, "Fetch preparation error: ${e.message}") + if (continuation.isActive) { + continuation.resumeWith(Result.success(gson.toJson(mapOf( + "ok" to false, + "status" to 0, + "statusText" to (e.message ?: "Fetch failed"), + "url" to url, + "body" to "", + "headers" to emptyMap() + )))) + } + } + } + + private data class BoundedReadResult( + val bytes: ByteArray, + val truncated: Boolean + ) + + private fun truncateString(value: String, maxChars: Int): String { + if (value.length <= maxChars) return value + val end = maxChars - FETCH_TRUNCATION_SUFFIX.length + if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars) + return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX + } + + private fun decodeBodyToSafeString(bytes: ByteArray, charset: java.nio.charset.Charset): String { + val decoded = try { + String(bytes, charset) + } catch (e: Exception) { + String(bytes, Charsets.UTF_8) + } + return truncateString(decoded, MAX_FETCH_BODY_CHARS) + } + + private fun readAtMostBytes(stream: InputStream, maxBytes: Int): BoundedReadResult { + val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) + val buffer = ByteArray(8 * 1024) + var remaining = maxBytes + var truncated = false + + while (remaining > 0) { + val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) + if (read <= 0) break + out.write(buffer, 0, read) + remaining -= read + } + if (remaining == 0) { + truncated = stream.read() != -1 + } + return BoundedReadResult(out.toByteArray(), truncated) + } + + private fun parseUrl(urlString: String): String { + return try { + val url = URL(urlString) + gson.toJson(mapOf( + "protocol" to "${url.protocol}:", + "host" to if (url.port > 0) "${url.host}:${url.port}" else url.host, + "hostname" to url.host, + "port" to if (url.port > 0) url.port.toString() else "", + "pathname" to (url.path ?: "/"), + "search" to if (url.query != null) "?${url.query}" else "", + "hash" to if (url.ref != null) "#${url.ref}" else "" + )) + } catch (e: Exception) { + gson.toJson(mapOf( + "protocol" to "", + "host" to "", + "hostname" to "", + "port" to "", + "pathname" to "/", + "search" to "", + "hash" to "" + )) + } + } + + private fun buildPolyfillCode(scraperId: String, settingsJson: String): String { + return """ + // Global constants (using globalThis to avoid redeclaration errors) + globalThis.SCRAPER_ID = "$scraperId"; + globalThis.SCRAPER_SETTINGS = $settingsJson; + if (typeof TMDB_API_KEY === 'undefined') { + globalThis.TMDB_API_KEY = "${BuildConfig.TMDB_API_KEY}"; + } + if (typeof globalThis.global === 'undefined') { + globalThis.global = globalThis; + } + if (typeof globalThis.window === 'undefined') { + globalThis.window = globalThis; + } + if (typeof globalThis.self === 'undefined') { + globalThis.self = globalThis; + } + + // Fetch implementation (async) + var fetch = async function(url, options) { + options = options || {}; + var method = (options.method || 'GET').toUpperCase(); + var headers = options.headers || {}; + var body = options.body || ''; + var signal = options.signal || null; + + if (signal && signal.aborted) { + var preErr = new Error('The operation was aborted.'); + preErr.name = 'AbortError'; + throw preErr; + } + + // Add default User-Agent + if (!headers['User-Agent']) { + headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; + } + + var result = await __native_fetch(url, method, JSON.stringify(headers), body); + var parsed = JSON.parse(result); + + if (signal && signal.aborted) { + var postErr = new Error('The operation was aborted.'); + postErr.name = 'AbortError'; + throw postErr; + } + + return { + ok: parsed.ok, + status: parsed.status, + statusText: parsed.statusText, + url: parsed.url, + headers: { + get: function(name) { + return parsed.headers[name.toLowerCase()] || null; + } + }, + text: function() { + return Promise.resolve(parsed.body); + }, + json: function() { + + try { + if (parsed.body === null || parsed.body === undefined || parsed.body === '') { + return Promise.resolve(null); + } + return Promise.resolve(JSON.parse(parsed.body)); + } catch (e) { + console.error('fetch.json parse error:', e && e.message ? e.message : e); + return Promise.resolve(null); + } + } + }; + }; + + // AbortController/AbortSignal minimal polyfill + if (typeof AbortSignal === 'undefined') { + var AbortSignal = function() { + this.aborted = false; + this.reason = undefined; + this._listeners = []; + }; + AbortSignal.prototype.addEventListener = function(type, listener) { + if (type !== 'abort' || typeof listener !== 'function') return; + this._listeners.push(listener); + }; + AbortSignal.prototype.removeEventListener = function(type, listener) { + if (type !== 'abort') return; + this._listeners = this._listeners.filter(function(l) { return l !== listener; }); + }; + AbortSignal.prototype.dispatchEvent = function(event) { + if (!event || event.type !== 'abort') return true; + for (var i = 0; i < this._listeners.length; i++) { + try { this._listeners[i].call(this, event); } catch (e) {} + } + return true; + }; + globalThis.AbortSignal = AbortSignal; + } + if (typeof AbortController === 'undefined') { + var AbortController = function() { + this.signal = new AbortSignal(); + }; + AbortController.prototype.abort = function(reason) { + if (this.signal.aborted) return; + this.signal.aborted = true; + this.signal.reason = reason; + this.signal.dispatchEvent({ type: 'abort' }); + }; + globalThis.AbortController = AbortController; + } + + // atob/btoa polyfills + if (typeof atob === 'undefined') { + globalThis.atob = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input).replace(/=+$/, ''); + if (str.length % 4 === 1) { + throw new Error('InvalidCharacterError'); + } + var output = ''; + var bc = 0, bs, buffer, idx = 0; + while ((buffer = str.charAt(idx++))) { + buffer = chars.indexOf(buffer); + if (buffer === -1) continue; + bs = bc % 4 ? bs * 64 + buffer : buffer; + if (bc++ % 4) { + output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))); + } + } + return output; + }; + } + if (typeof btoa === 'undefined') { + globalThis.btoa = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input); + var output = ''; + for ( + var block, charCode, idx = 0, map = chars; + str.charAt(idx | 0) || (map = '=', idx % 1); + output += map.charAt(63 & (block >> (8 - (idx % 1) * 8))) + ) { + charCode = str.charCodeAt(idx += 3 / 4); + if (charCode > 0xFF) { + throw new Error('InvalidCharacterError'); + } + block = (block << 8) | charCode; + } + return output; + }; + } + + // URL class + var URL = function(urlString, base) { + var fullUrl = urlString; + if (base && !/^https?:\/\//i.test(urlString)) { + // Resolve relative URL against base + var b = typeof base === 'string' ? base : base.href; + if (urlString.charAt(0) === '/') { + var m = b.match(/^(https?:\/\/[^\/]+)/); + fullUrl = m ? m[1] + urlString : urlString; + } else { + fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString; + } + } + var parsed = __parse_url(fullUrl); + var data = JSON.parse(parsed); + this.href = fullUrl; + this.protocol = data.protocol; + this.host = data.host; + this.hostname = data.hostname; + this.port = data.port; + this.pathname = data.pathname; + this.search = data.search; + this.hash = data.hash; + this.origin = data.protocol + '//' + data.host; + // Build searchParams from search string + this.searchParams = new URLSearchParams(data.search || ''); + }; + URL.prototype.toString = function() { return this.href; }; + + // URLSearchParams class + var URLSearchParams = function(init) { + this._params = {}; + var self = this; + if (init && typeof init === 'object' && !Array.isArray(init)) { + Object.keys(init).forEach(function(key) { + self._params[key] = String(init[key]); + }); + } else if (typeof init === 'string') { + init.replace(/^\?/, '').split('&').forEach(function(pair) { + var parts = pair.split('='); + if (parts[0]) { + self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || ''); + } + }); + } + }; + URLSearchParams.prototype.toString = function() { + var self = this; + return Object.keys(this._params).map(function(key) { + return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]); + }).join('&'); + }; + URLSearchParams.prototype.get = function(key) { + return this._params.hasOwnProperty(key) ? this._params[key] : null; + }; + URLSearchParams.prototype.set = function(key, value) { + this._params[key] = String(value); + }; + URLSearchParams.prototype.append = function(key, value) { + this._params[key] = String(value); + }; + URLSearchParams.prototype.has = function(key) { + return this._params.hasOwnProperty(key); + }; + URLSearchParams.prototype.delete = function(key) { + delete this._params[key]; + }; + URLSearchParams.prototype.keys = function() { + return Object.keys(this._params); + }; + URLSearchParams.prototype.values = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return self._params[k]; }); + }; + URLSearchParams.prototype.entries = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; }); + }; + URLSearchParams.prototype.forEach = function(callback) { + var self = this; + Object.keys(this._params).forEach(function(key) { + callback(self._params[key], key, self); + }); + }; + URLSearchParams.prototype.getAll = function(key) { + return this._params.hasOwnProperty(key) ? [this._params[key]] : []; + }; + URLSearchParams.prototype.sort = function() { + var sorted = {}; + var self = this; + Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; }); + this._params = sorted; + }; + + // Cheerio implementation + var cheerio = { + load: function(html) { + var docId = __cheerio_load(html); + + var $ = function(selector, context) { + // Handle $(wrapper) - return wrapper as-is + if (selector && selector._elementIds) { + return selector; + } + // Handle $(selector, context) pattern + if (context && context._elementIds && context._elementIds.length > 0) { + // Search within context element + var allIds = []; + for (var i = 0; i < context._elementIds.length; i++) { + var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + } + // Standard $(selector) call + return createCheerioWrapper(docId, selector); + }; + + $.html = function(el) { + if (el && el._elementIds && el._elementIds.length > 0) { + return __cheerio_html(docId, el._elementIds[0]); + } + return __cheerio_html(docId, ''); + }; + + return $; + } + }; + + function createCheerioWrapper(docId, selector) { + var elementIds; + if (typeof selector === 'string') { + var idsJson = __cheerio_select(docId, selector); + elementIds = JSON.parse(idsJson); + } else { + elementIds = []; + } + + var wrapper = { + _docId: docId, + _elementIds: elementIds, + length: elementIds.length, + + each: function(callback) { + for (var i = 0; i < elementIds.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [elementIds[i]]); + callback.call(elWrapper, i, elWrapper); + } + return wrapper; + }, + + find: function(sel) { + var allIds = []; + for (var i = 0; i < elementIds.length; i++) { + var childIdsJson = __cheerio_find(docId, elementIds[i], sel); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + }, + + text: function() { + if (elementIds.length === 0) return ''; + return __cheerio_text(docId, elementIds.join(',')); + }, + + html: function() { + if (elementIds.length === 0) return ''; + return __cheerio_inner_html(docId, elementIds[0]); + }, + + attr: function(name) { + if (elementIds.length === 0) return undefined; + var val = __cheerio_attr(docId, elementIds[0], name); + return val === '__UNDEFINED__' ? undefined : val; + }, + + first: function() { + return createCheerioWrapperFromIds(docId, elementIds.length > 0 ? [elementIds[0]] : []); + }, + + last: function() { + return createCheerioWrapperFromIds(docId, elementIds.length > 0 ? [elementIds[elementIds.length - 1]] : []); + }, + + next: function() { + var nextIds = []; + for (var i = 0; i < elementIds.length; i++) { + var nextId = __cheerio_next(docId, elementIds[i]); + if (nextId && nextId !== '__NONE__') { + nextIds.push(nextId); + } + } + return createCheerioWrapperFromIds(docId, nextIds); + }, + + prev: function() { + var prevIds = []; + for (var i = 0; i < elementIds.length; i++) { + var prevId = __cheerio_prev(docId, elementIds[i]); + if (prevId && prevId !== '__NONE__') { + prevIds.push(prevId); + } + } + return createCheerioWrapperFromIds(docId, prevIds); + }, + + eq: function(index) { + if (index >= 0 && index < elementIds.length) { + return createCheerioWrapperFromIds(docId, [elementIds[index]]); + } + return createCheerioWrapperFromIds(docId, []); + }, + + get: function(index) { + if (typeof index === 'number') { + if (index >= 0 && index < elementIds.length) { + return createCheerioWrapperFromIds(docId, [elementIds[index]]); + } + return undefined; + } + return elementIds.map(function(id) { + return createCheerioWrapperFromIds(docId, [id]); + }); + }, + + map: function(callback) { + var results = []; + for (var i = 0; i < elementIds.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [elementIds[i]]); + var result = callback.call(elWrapper, i, elWrapper); + if (result !== undefined && result !== null) { + results.push(result); + } + } + // Return object with get() for cheerio compatibility + return { + length: results.length, + get: function(index) { + if (typeof index === 'number') { + return results[index]; + } + return results; + }, + toArray: function() { + return results; + } + }; + }, + + filter: function(selectorOrCallback) { + if (typeof selectorOrCallback === 'function') { + var filteredIds = []; + for (var i = 0; i < elementIds.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [elementIds[i]]); + var result = selectorOrCallback.call(elWrapper, i, elWrapper); + if (result) { + filteredIds.push(elementIds[i]); + } + } + return createCheerioWrapperFromIds(docId, filteredIds); + } + return wrapper; + }, + + children: function(sel) { + return this.find(sel || '*'); + }, + + parent: function() { + return createCheerioWrapperFromIds(docId, []); + }, + + toArray: function() { + return elementIds.map(function(id) { + return createCheerioWrapperFromIds(docId, [id]); + }); + } + }; + + return wrapper; + } + + function createCheerioWrapperFromIds(docId, ids) { + var wrapper = { + _docId: docId, + _elementIds: ids, + length: ids.length, + + each: function(callback) { + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + callback.call(elWrapper, i, elWrapper); + } + return wrapper; + }, + + find: function(sel) { + var allIds = []; + for (var i = 0; i < ids.length; i++) { + var childIdsJson = __cheerio_find(docId, ids[i], sel); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + }, + + text: function() { + if (ids.length === 0) return ''; + return __cheerio_text(docId, ids.join(',')); + }, + + html: function() { + if (ids.length === 0) return ''; + return __cheerio_inner_html(docId, ids[0]); + }, + + attr: function(name) { + if (ids.length === 0) return undefined; + var val = __cheerio_attr(docId, ids[0], name); + return val === '__UNDEFINED__' ? undefined : val; + }, + + first: function() { + return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); + }, + + last: function() { + return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); + }, + + next: function() { + var nextIds = []; + for (var i = 0; i < ids.length; i++) { + var nextId = __cheerio_next(docId, ids[i]); + if (nextId && nextId !== '__NONE__') { + nextIds.push(nextId); + } + } + return createCheerioWrapperFromIds(docId, nextIds); + }, + + prev: function() { + var prevIds = []; + for (var i = 0; i < ids.length; i++) { + var prevId = __cheerio_prev(docId, ids[i]); + if (prevId && prevId !== '__NONE__') { + prevIds.push(prevId); + } + } + return createCheerioWrapperFromIds(docId, prevIds); + }, + + eq: function(index) { + if (index >= 0 && index < ids.length) { + return createCheerioWrapperFromIds(docId, [ids[index]]); + } + return createCheerioWrapperFromIds(docId, []); + }, + + get: function(index) { + if (typeof index === 'number') { + if (index >= 0 && index < ids.length) { + return createCheerioWrapperFromIds(docId, [ids[index]]); + } + return undefined; + } + return ids.map(function(id) { + return createCheerioWrapperFromIds(docId, [id]); + }); + }, + + map: function(callback) { + var results = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = callback.call(elWrapper, i, elWrapper); + if (result !== undefined && result !== null) { + results.push(result); + } + } + // Return object with get() for cheerio compatibility + return { + length: results.length, + get: function(index) { + if (typeof index === 'number') { + return results[index]; + } + return results; + }, + toArray: function() { + return results; + } + }; + }, + + filter: function(selectorOrCallback) { + if (typeof selectorOrCallback === 'function') { + var filteredIds = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = selectorOrCallback.call(elWrapper, i, elWrapper); + if (result) { + filteredIds.push(ids[i]); + } + } + return createCheerioWrapperFromIds(docId, filteredIds); + } + return wrapper; + }, + + children: function(sel) { + return this.find(sel || '*'); + }, + + parent: function() { + return createCheerioWrapperFromIds(docId, []); + }, + + toArray: function() { + return ids.map(function(id) { + return createCheerioWrapperFromIds(docId, [id]); + }); + } + }; + + return wrapper; + } + + // Require function for CommonJS modules + var require = function(moduleName) { + if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') { + return cheerio; + } + if (moduleName === 'crypto-js') { + if (globalThis.CryptoJS) return globalThis.CryptoJS; + throw new Error("Module 'crypto-js' is not loaded"); + } + throw new Error("Module '" + moduleName + "' is not available"); + }; + + // Array.prototype.flat polyfill + if (!Array.prototype.flat) { + Array.prototype.flat = function(depth) { + depth = depth === undefined ? 1 : Math.floor(depth); + if (depth < 1) return Array.prototype.slice.call(this); + return (function flatten(arr, d) { + return d > 0 + ? arr.reduce(function(acc, val) { + return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); + }, []) + : arr.slice(); + })(this, depth); + }; + } + + // Array.prototype.flatMap polyfill + if (!Array.prototype.flatMap) { + Array.prototype.flatMap = function(callback, thisArg) { + return this.map(callback, thisArg).flat(); + }; + } + + // Object.entries polyfill + if (!Object.entries) { + Object.entries = function(obj) { + var result = []; + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + result.push([key, obj[key]]); + } + } + return result; + }; + } + + // Object.fromEntries polyfill + if (!Object.fromEntries) { + Object.fromEntries = function(entries) { + var result = {}; + for (var i = 0; i < entries.length; i++) { + result[entries[i][0]] = entries[i][1]; + } + return result; + }; + } + + // String.prototype.replaceAll polyfill + if (!String.prototype.replaceAll) { + String.prototype.replaceAll = function(search, replace) { + if (search instanceof RegExp) { + if (!search.global) { + throw new TypeError('replaceAll must be called with a global RegExp'); + } + return this.replace(search, replace); + } + return this.split(search).join(replace); + }; + } + """.trimIndent() + } + + private fun parseJsonResults(json: String): List { + return try { + val listType = object : com.google.gson.reflect.TypeToken>>() {}.type + val results: List>? = gson.fromJson(json, listType) + results?.mapNotNull { item -> + // Handle URL - could be string or object with url property + val urlValue = item["url"] + val url = when (urlValue) { + is String -> urlValue.takeIf { it.isNotBlank() && !it.contains("[object") } + is Map<*, *> -> (urlValue["url"] as? String)?.takeIf { it.isNotBlank() } + else -> null + } ?: return@mapNotNull null + + // Parse headers if present + val headersValue = item["headers"] + val headers: Map? = when (headersValue) { + is Map<*, *> -> headersValue.entries + .filter { it.key is String && it.value is String } + .associate { (it.key as String) to (it.value as String) } + .takeIf { it.isNotEmpty() } + else -> null + } + + LocalScraperResult( + title = item["title"]?.toString()?.takeIf { !it.contains("[object") } + ?: item["name"]?.toString()?.takeIf { !it.contains("[object") } + ?: "Unknown", + name = item["name"]?.toString()?.takeIf { !it.contains("[object") }, + url = url, + quality = item["quality"]?.toString()?.takeIf { !it.contains("[object") }, + size = item["size"]?.toString()?.takeIf { !it.contains("[object") }, + language = item["language"]?.toString()?.takeIf { !it.contains("[object") }, + provider = item["provider"]?.toString()?.takeIf { !it.contains("[object") }, + type = item["type"]?.toString()?.takeIf { !it.contains("[object") }, + seeders = (item["seeders"] as? Number)?.toInt(), + peers = (item["peers"] as? Number)?.toInt(), + infoHash = item["infoHash"]?.toString()?.takeIf { !it.contains("[object") }, + headers = headers + ) + }?.filter { it.url.isNotBlank() } ?: emptyList() + } catch (e: Exception) { + Log.e(TAG, "Failed to parse results: ${e.message}") + emptyList() + } + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtensionLoader.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtensionLoader.kt new file mode 100644 index 00000000..fde83201 --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtensionLoader.kt @@ -0,0 +1,761 @@ +package com.arflix.tv.core.plugin.cloudstream + +import android.content.Context +import android.os.Build +import android.util.Log +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.plugins.BasePlugin +import com.lagradost.cloudstream3.plugins.Plugin +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.extractorApis +import com.arflix.tv.core.plugin.TestDiagnostics +import dalvik.system.DexClassLoader +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "ExtExtensionLoader" + +/** + * Checks whether an instance looks like a CloudStream plugin by checking if it has + * plugin-like methods. Covers three cases: + * 1. Our Plugin class: load(Activity?), load(Context), getRegisteredMainAPIs() + * 2. Library's BasePlugin: load() (no-arg), registerMainAPI() + * 3. Foreign plugins with similar signatures + */ +private fun looksLikePlugin(instance: Any): Boolean { + // Fast path: check if it's an instance of the library's BasePlugin + if (instance is BasePlugin) return true + + val clazz = instance.javaClass + // Check for any form of load() method + val hasLoad = try { + clazz.getMethod("load", Context::class.java) != null + } catch (_: NoSuchMethodException) { + try { + clazz.getMethod("load", android.app.Activity::class.java) != null + } catch (_: NoSuchMethodException) { + try { + // BasePlugin-style no-arg load() + clazz.getMethod("load") != null + } catch (_: NoSuchMethodException) { + false + } + } + } + val hasRegisteredAPIs = try { + clazz.getMethod("getRegisteredMainAPIs") != null + } catch (_: NoSuchMethodException) { + false + } + return hasLoad || hasRegisteredAPIs +} + +/** + * Wraps a plugin instance loaded from a foreign classloader or with a non-standard base class. + * Handles three plugin patterns: + * 1. Our Plugin: load(Activity?), load(Context), getRegisteredMainAPIs() + * 2. Library's BasePlugin: load() no-arg, registers to APIHolder.allProviders + extractorApis + * 3. Foreign plugins with similar signatures + */ +private class ReflectivePluginWrapper(private val foreignInstance: Any) : Plugin() { + override fun load(activity: android.app.Activity?) { + load(activity as? Context ?: AcraApplication.context ?: return) + } + + override fun load(context: Context) { + // Snapshot global registries before load() to detect what this plugin adds + val providersBefore = synchronized(APIHolder.allProviders) { + APIHolder.allProviders.toList() + } + val extractorsBefore = extractorApis.toList() + + val clazz = foreignInstance.javaClass + var loaded = false + + // Try load(Context) first + try { + val m = clazz.getMethod("load", Context::class.java) + m.invoke(foreignInstance, context) + loaded = true + } catch (e: java.lang.reflect.InvocationTargetException) { + val cause = e.cause + if (cause is ClassCastException) { + Log.d(TAG, "ReflectivePluginWrapper: load(Context) got ClassCastException, retrying with null Activity") + try { + val m = clazz.getMethod("load", android.app.Activity::class.java) + m.invoke(foreignInstance, null) + loaded = true + } catch (e2: Exception) { + Log.w(TAG, "ReflectivePluginWrapper: load(Activity) also failed: ${e2.message}") + } + } else { + Log.w(TAG, "ReflectivePluginWrapper: load(Context) threw: ${cause?.message ?: e.message}") + } + } catch (_: NoSuchMethodException) { + // Try load(Activity?) next + try { + val m = clazz.getMethod("load", android.app.Activity::class.java) + m.invoke(foreignInstance, null) + loaded = true + } catch (_: NoSuchMethodException) { + // Try no-arg load() (BasePlugin pattern) + try { + val m = clazz.getMethod("load") + m.invoke(foreignInstance) + loaded = true + Log.d(TAG, "ReflectivePluginWrapper: loaded via no-arg load()") + } catch (e: Exception) { + Log.w(TAG, "ReflectivePluginWrapper: load() (no-arg) failed: ${e.message}") + } + } catch (e: Exception) { + Log.w(TAG, "ReflectivePluginWrapper: load(Activity) failed: ${e.message}") + } + } catch (e: Exception) { + Log.w(TAG, "ReflectivePluginWrapper: load() failed: ${e.message}") + } + + // First try reading local registration lists (our Plugin pattern) + try { + val getter = clazz.getMethod("getRegisteredMainAPIs") + @Suppress("UNCHECKED_CAST") + val apis = getter.invoke(foreignInstance) as? List ?: emptyList() + apis.forEach { registerMainAPI(it) } + } catch (_: Exception) { + // No local list — check global APIHolder for newly registered providers + // (BasePlugin.registerMainAPI adds to APIHolder.allProviders) + val newProviders = synchronized(APIHolder.allProviders) { + APIHolder.allProviders.toList() + } - providersBefore.toSet() + if (newProviders.isNotEmpty()) { + Log.d(TAG, "ReflectivePluginWrapper: found ${newProviders.size} providers via APIHolder") + newProviders.forEach { registerMainAPI(it) } + } + } + + try { + val getter = clazz.getMethod("getRegisteredExtractorAPIs") + @Suppress("UNCHECKED_CAST") + val extractors = getter.invoke(foreignInstance) as? List ?: emptyList() + extractors.forEach { registerExtractorAPI(it) } + } catch (_: Exception) { + // Check global extractorApis for newly registered extractors + val newExtractors = extractorApis.toList() - extractorsBefore.toSet() + if (newExtractors.isNotEmpty()) { + Log.d(TAG, "ReflectivePluginWrapper: found ${newExtractors.size} extractors via extractorApis") + newExtractors.forEach { registerExtractorAPI(it) } + } + } + } +} + +private const val MAX_DEX_SIZE = 10 * 1024 * 1024L // 10MB max per .cs3 file + +/** + * Manages downloading, loading, and caching of DEX-based external extensions (.cs3 files). + */ +@Singleton +class ExternalExtensionLoader @Inject constructor( + @ApplicationContext private val context: Context, + private val extractorRegistry: ExternalExtractorRegistry +) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .followRedirects(true) + .build() + + /** Cache of loaded MainAPI instances by scraper ID */ + private val apiCache = ConcurrentHashMap() + + /** Cache of loaded class loaders by scraper ID */ + private val classLoaderCache = ConcurrentHashMap() + + /** Tracks which scraper IDs have already been scanned for extractors */ + private val extractorPreloadedIds = java.util.Collections.synchronizedSet(mutableSetOf()) + + private val extensionsDir: File + get() = File(context.filesDir, "cs_extensions").also { it.mkdirs() } + + private val codeCacheDir: File + get() = File(context.codeCacheDir, "cs_dex_cache").also { it.mkdirs() } + + /** Sanitize scraper ID for use as a filename (colons are not safe on all filesystems). */ + private fun safeFileName(scraperId: String): String = + scraperId.replace(':', '_').replace('/', '_') + + /** + * Download a .cs3 DEX file for the given scraper. + * Returns the local file path, or null on failure. + */ + suspend fun downloadExtension(scraperId: String, downloadUrl: String): File? = withContext(Dispatchers.IO) { + com.arflix.tv.core.runtime.PluginRuntimeHooks.ensureCloudstreamInitialized() + try { + val targetFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3") + + // Remove existing read-only file before writing (DEX files are set + // read-only for API 28+ compat, so overwriting would fail with EACCES) + if (targetFile.exists()) { + targetFile.setWritable(true) + targetFile.delete() + } + + val request = Request.Builder() + .url(downloadUrl) + .header("User-Agent", "NuvioTV/1.0") + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.e(TAG, "Failed to download extension $scraperId: HTTP ${response.code}") + return@withContext null + } + + val contentLength = response.header("Content-Length")?.toLongOrNull() ?: 0 + if (contentLength > MAX_DEX_SIZE) { + Log.w(TAG, "Extension $scraperId too large: $contentLength bytes") + return@withContext null + } + + val bytes = response.body?.bytes() ?: return@withContext null + if (bytes.size > MAX_DEX_SIZE) { + Log.w(TAG, "Extension $scraperId too large: ${bytes.size} bytes") + return@withContext null + } + + targetFile.writeBytes(bytes) + + // Fix for Android API 28+: DEX files must be read-only + // Writing writable DEX files is blocked on newer Android versions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + targetFile.setReadOnly() + Log.d(TAG, "Set DEX file read-only for API ${Build.VERSION.SDK_INT}") + } + + Log.d(TAG, "Downloaded extension $scraperId: ${bytes.size} bytes -> ${targetFile.absolutePath}") + targetFile + } + } catch (e: Exception) { + Log.e(TAG, "Error downloading extension $scraperId: ${e.message}", e) + null + } + } + + /** + * Load a .cs3 DEX file and return the MainAPI instance(s) registered by the plugin. + */ + fun loadExtension(scraperId: String): List { + // Check cache first + apiCache[scraperId]?.let { return listOf(it) } + com.arflix.tv.core.runtime.PluginRuntimeHooks.ensureCloudstreamInitialized() + + val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3") + if (!dexFile.exists()) { + Log.e(TAG, "DEX file not found for $scraperId: ${dexFile.absolutePath}") + return emptyList() + } + + // Ensure DEX file is read-only (fix for existing downloads on API 28+) + ensureDexReadOnly(dexFile) + + return try { + val classLoader = DexClassLoader( + dexFile.absolutePath, + codeCacheDir.absolutePath, + null, + context.classLoader + ) + classLoaderCache[scraperId] = classLoader + + // Check for critical class shadowing + try { + @Suppress("DEPRECATION") + val inspectDex = dalvik.system.DexFile(dexFile) + val allEntries = inspectDex.entries().toList() + inspectDex.close() + val criticalShadows = allEntries.filter { className -> + className == "com.lagradost.cloudstream3.MainActivityKt" || + className == "com.lagradost.cloudstream3.MainAPIKt" || + className == "com.lagradost.cloudstream3.utils.ExtractorApiKt" || + className == "com.lagradost.cloudstream3.utils.AppUtilsKt" + } + if (criticalShadows.isNotEmpty()) { + Log.w(TAG, "Extension $scraperId shadows critical classes: $criticalShadows") + } + } catch (_: Exception) {} + + // Find and instantiate the plugin class + val plugin = findAndLoadPlugin(classLoader, dexFile) + if (plugin == null) { + Log.e(TAG, "No @CloudstreamPlugin class found in $scraperId") + return emptyList() + } + + // Ensure global stubs are initialized for extensions + AcraApplication.context = context + extractorRegistry.installGlobal() + + // Call load() to trigger registerMainAPI() calls. + // Dispatch on Context so plugins that override load(Context) — upstream's + // primary overload — get their impl invoked. Plugins that only override + // load(Activity?) or no-arg load() still work: stub's load(Context) casts + // the arg to Activity? and chains through. + val activity = AcraApplication.getActivity() + try { + plugin.load((activity as Context?) ?: context) + } catch (e: Exception) { + Log.w(TAG, "plugin.load() threw (partial load, ${plugin.registeredMainAPIs.size} APIs so far): ${e.message}", e) + } catch (e: Error) { + val missingClass = extractMissingClassName(e) + if (missingClass != null) { + Log.w(TAG, "plugin.load() MISSING CLASS: $missingClass (${plugin.registeredMainAPIs.size} APIs so far)", e) + } else { + Log.w(TAG, "plugin.load() linkage error (partial load, ${plugin.registeredMainAPIs.size} APIs so far): ${e.message}", e) + } + } + + // Register any extractors the plugin provides + extractorRegistry.registerAll(plugin.registeredExtractorAPIs) + + var apis = plugin.registeredMainAPIs + Log.d(TAG, "After load(): ${apis.size} MainAPIs, ${plugin.registeredExtractorAPIs.size} extractors") + + // FALLBACK: If load() registered 0 APIs or 0 extractors, scan DEX directly. + if (apis.isEmpty() || plugin.registeredExtractorAPIs.isEmpty()) { + Log.d(TAG, "Fallback: scanning DEX for MainAPI/ExtractorApi subclasses in $scraperId") + val fallbackApis = mutableListOf() + val fallbackExtractors = mutableListOf() + try { + @Suppress("DEPRECATION") + val inspectDex = dalvik.system.DexFile(dexFile) + val allClasses = inspectDex.entries().toList() + inspectDex.close() + + val candidates = allClasses.filter { className -> + !className.contains('$') && + !className.contains("Plugin") && + !className.contains("Fragment") && + className.startsWith("com.") + } + + for (className in candidates) { + try { + val clazz = classLoader.loadClass(className) + if (apis.isEmpty() + && MainAPI::class.java.isAssignableFrom(clazz) + && !java.lang.reflect.Modifier.isAbstract(clazz.modifiers) + && !clazz.isInterface) { + val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI + fallbackApis.add(instance) + Log.d(TAG, "Fallback found MainAPI: ${instance.name} ($className)") + } else if (ExtractorApi::class.java.isAssignableFrom(clazz) + && !java.lang.reflect.Modifier.isAbstract(clazz.modifiers)) { + val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi + fallbackExtractors.add(instance) + Log.d(TAG, "Fallback found ExtractorApi: ${instance.name} (${instance.mainUrl})") + } + } catch (e: Error) { + val missing = extractMissingClassName(e) + if (missing != null) { + Log.w(TAG, "Fallback skip $className: MISSING $missing") + } + } catch (_: Exception) { + // Skip classes that can't be instantiated + } + } + } catch (e: Exception) { + Log.w(TAG, "Fallback DEX scan failed: ${e.message}") + } + + if (fallbackApis.isNotEmpty()) { + Log.d(TAG, "Fallback found ${fallbackApis.size} MainAPIs") + apis = fallbackApis + } + if (fallbackExtractors.isNotEmpty()) { + Log.d(TAG, "Fallback found ${fallbackExtractors.size} ExtractorApis") + extractorRegistry.registerAll(fallbackExtractors) + } + } + + apis.forEach { api -> + apiCache["$scraperId:${api.name}"] = api + } + + // Also cache the first API under the plain scraper ID + if (apis.isNotEmpty()) { + apiCache[scraperId] = apis.first() + } + + Log.d(TAG, "Loaded extension $scraperId: ${apis.size} providers (${apis.joinToString { it.name }})") + apis + } catch (e: Exception) { + Log.e(TAG, "Failed to load extension $scraperId: ${e.message}", e) + emptyList() + } catch (e: Error) { + val missingClass = extractMissingClassName(e) + if (missingClass != null) { + Log.e(TAG, "Failed to load extension $scraperId: MISSING CLASS: $missingClass (${e.javaClass.simpleName})", e) + } else { + Log.e(TAG, "Failed to load extension $scraperId (linkage error): ${e.message}", e) + } + emptyList() + } + } + + /** + * Get a cached MainAPI for the given scraper ID, loading if necessary. + */ + fun getApi(scraperId: String): MainAPI? { + return apiCache[scraperId] ?: run { + val apis = loadExtension(scraperId) + apis.firstOrNull() + } + } + + /** + * Load extension with diagnostic output. + */ + fun loadExtensionWithDiagnostics(scraperId: String, diagnostics: TestDiagnostics): List { + apiCache[scraperId]?.let { + diagnostics.addStep("MainAPI cached: ${it.name}") + return listOf(it) + } + com.arflix.tv.core.runtime.PluginRuntimeHooks.ensureCloudstreamInitialized() + + val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3") + if (!dexFile.exists()) { + diagnostics.addStep("DEX file NOT FOUND: ${dexFile.name}") + return emptyList() + } + diagnostics.addStep("DEX: ${dexFile.length()} bytes") + + // Ensure read-only + ensureDexReadOnly(dexFile) + + return try { + val classLoader = DexClassLoader( + dexFile.absolutePath, + codeCacheDir.absolutePath, + null, + context.classLoader + ) + classLoaderCache[scraperId] = classLoader + + val allClasses: List + try { + @Suppress("DEPRECATION") + val inspectDex = dalvik.system.DexFile(dexFile) + allClasses = inspectDex.entries().toList() + inspectDex.close() + } catch (e: Exception) { + diagnostics.addStep("DEX inspection failed: ${e.message?.take(100)}") + return emptyList() + } + + val shadowsPlugin = allClasses.any { it == "com.lagradost.cloudstream3.plugins.Plugin" } + if (shadowsPlugin) { + diagnostics.addStep("WARNING: DEX contains its own Plugin class!") + } + + val plugin = findAndLoadPlugin(classLoader, dexFile) + if (plugin == null) { + diagnostics.addStep("No @CloudstreamPlugin found in DEX") + return emptyList() + } + + val sameClass = plugin.javaClass.superclass == Plugin::class.java + diagnostics.addStep("Plugin: ${plugin.javaClass.simpleName}, sameBaseClass=$sameClass, isWrapper=${plugin is ReflectivePluginWrapper}") + + AcraApplication.context = context + extractorRegistry.installGlobal() + + val activity = AcraApplication.getActivity() + try { + plugin.load((activity as Context?) ?: context) + diagnostics.addStep("load(Context): OK, ${plugin.registeredMainAPIs.size} APIs") + } catch (e: Exception) { + diagnostics.addStep("load() FAILED: ${e.javaClass.simpleName}: ${e.message?.take(120)}") + } catch (e: Error) { + val missing = extractMissingClassName(e) + diagnostics.addStep("load() ERROR: ${missing ?: e.message?.take(120)}") + } + + extractorRegistry.registerAll(plugin.registeredExtractorAPIs) + + var apis = plugin.registeredMainAPIs + + if (apis.isEmpty() || plugin.registeredExtractorAPIs.isEmpty()) { + diagnostics.addStep("Fallback: scanning DEX for MainAPI + ExtractorApi subclasses...") + val fallbackApis = mutableListOf() + val fallbackExtractors = mutableListOf() + val candidates = allClasses.filter { className -> + !className.contains('$') && + !className.contains("Plugin") && + !className.contains("Fragment") && + className.startsWith("com.") + } + + for (className in candidates) { + try { + val clazz = classLoader.loadClass(className) + if (apis.isEmpty() + && MainAPI::class.java.isAssignableFrom(clazz) + && !java.lang.reflect.Modifier.isAbstract(clazz.modifiers) + && !clazz.isInterface) { + val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI + fallbackApis.add(instance) + diagnostics.addStep("Found API: ${instance.name} (${clazz.simpleName})") + } else if (ExtractorApi::class.java.isAssignableFrom(clazz) + && !java.lang.reflect.Modifier.isAbstract(clazz.modifiers)) { + val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi + fallbackExtractors.add(instance) + diagnostics.addStep("Found Extractor: ${instance.name} (${instance.mainUrl})") + } + } catch (e: Error) { + val missing = extractMissingClassName(e) + if (missing != null) { + diagnostics.addStep("${className.substringAfterLast('.')}: MISSING $missing") + } + } catch (e: Exception) { + val cause = e.cause ?: e + if (cause is Error) { + val missing = extractMissingClassName(cause as Error) + if (missing != null) { + diagnostics.addStep("${className.substringAfterLast('.')}: MISSING $missing") + } + } + } + } + + if (fallbackApis.isNotEmpty()) { + diagnostics.addStep("Fallback found ${fallbackApis.size} APIs") + apis = fallbackApis + } + if (fallbackExtractors.isNotEmpty()) { + diagnostics.addStep("Fallback found ${fallbackExtractors.size} extractors") + extractorRegistry.registerAll(fallbackExtractors) + } + } + + apis.forEach { api -> + apiCache["$scraperId:${api.name}"] = api + } + if (apis.isNotEmpty()) { + apiCache[scraperId] = apis.first() + } + + apis + } catch (e: Exception) { + diagnostics.addStep("FAILED: ${e.javaClass.simpleName}: ${e.message?.take(200)}") + emptyList() + } catch (e: Error) { + val missing = extractMissingClassName(e) + diagnostics.addStep("FAILED: ${missing ?: e.message?.take(200)}") + emptyList() + } + } + + /** + * Eagerly load all ExtractorApi subclasses from the given .cs3 files. + */ + fun ensureExtractorsLoaded(scraperIds: List, diagnostics: TestDiagnostics? = null) { + com.arflix.tv.core.runtime.PluginRuntimeHooks.ensureCloudstreamInitialized() + val idsToLoad = scraperIds.filter { it !in extractorPreloadedIds } + if (idsToLoad.isEmpty()) { + diagnostics?.addStep("Extractors: all ${scraperIds.size} already preloaded") + return + } + + AcraApplication.context = context + extractorRegistry.installGlobal() + + var totalExtractors = 0 + var totalScanned = 0 + + for (scraperId in idsToLoad) { + extractorPreloadedIds.add(scraperId) + + val dexFile = File(extensionsDir, "${safeFileName(scraperId)}.cs3") + if (!dexFile.exists()) continue + + // Ensure read-only + ensureDexReadOnly(dexFile) + + totalScanned++ + try { + val classLoader = classLoaderCache.getOrPut(scraperId) { + DexClassLoader( + dexFile.absolutePath, + codeCacheDir.absolutePath, + null, + context.classLoader + ) + } + + val plugin = findAndLoadPlugin(classLoader, dexFile) + if (plugin != null) { + val activity = AcraApplication.getActivity() + try { + plugin.load((activity as Context?) ?: context) + } catch (_: Exception) { + } catch (_: Error) { + } + if (plugin.registeredExtractorAPIs.isNotEmpty()) { + extractorRegistry.registerAll(plugin.registeredExtractorAPIs) + totalExtractors += plugin.registeredExtractorAPIs.size + continue + } + } + + @Suppress("DEPRECATION") + val inspectDex = dalvik.system.DexFile(dexFile) + val allClasses = inspectDex.entries().toList() + inspectDex.close() + + for (className in allClasses) { + if (className.contains('$')) continue + try { + val clazz = classLoader.loadClass(className) + if (ExtractorApi::class.java.isAssignableFrom(clazz) + && !java.lang.reflect.Modifier.isAbstract(clazz.modifiers) + ) { + val instance = clazz.getDeclaredConstructor().newInstance() as ExtractorApi + extractorRegistry.registerExtractor(instance) + totalExtractors++ + } + } catch (_: Exception) { + } catch (_: Error) { + } + } + } catch (e: Exception) { + Log.w(TAG, "ensureExtractorsLoaded: failed for $scraperId: ${e.message}") + } catch (e: Error) { + Log.w(TAG, "ensureExtractorsLoaded: linkage error for $scraperId: ${e.message}") + } + } + + Log.d(TAG, "ensureExtractorsLoaded: scanned $totalScanned .cs3 files, registered $totalExtractors extractors") + diagnostics?.addStep("Preloaded $totalExtractors extractors from $totalScanned .cs3 files") + } + + fun deleteExtension(scraperId: String) { + apiCache.keys.filter { it.startsWith(scraperId) }.forEach { apiCache.remove(it) } + classLoaderCache.remove(scraperId) + extractorPreloadedIds.remove(scraperId) + File(extensionsDir, "${safeFileName(scraperId)}.cs3").delete() + Log.d(TAG, "Deleted extension $scraperId") + } + + fun evictCache(scraperId: String) { + apiCache.keys.filter { it.startsWith(scraperId) }.forEach { apiCache.remove(it) } + classLoaderCache.remove(scraperId) + extractorPreloadedIds.remove(scraperId) + } + + /** + * Ensure a DEX file is read-only. Required for Android API 28+ which blocks + * writable DEX file loading. + */ + private fun ensureDexReadOnly(dexFile: File) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && dexFile.canWrite()) { + dexFile.setReadOnly() + Log.d(TAG, "Fixed DEX permissions (set read-only): ${dexFile.name}") + } + } + + private fun extractMissingClassName(e: Error): String? { + val msg = e.message ?: return null + val match = Regex("""(?:L?)([\w/.]+)(?:;)?""").find(msg) + return match?.groupValues?.get(1)?.replace('/', '.') + } + + private fun findAndLoadPlugin(classLoader: DexClassLoader, cs3File: File): Plugin? { + val pluginClassName = readPluginClassNameFromZip(cs3File) + if (pluginClassName != null) { + try { + Log.d(TAG, "Loading plugin class from manifest: $pluginClassName") + val clazz = classLoader.loadClass(pluginClassName) + val instance = clazz.getDeclaredConstructor().newInstance() + if (instance is Plugin) { + return instance + } + if (looksLikePlugin(instance)) { + Log.d(TAG, "Using reflective wrapper for $pluginClassName (non-standard base class)") + return ReflectivePluginWrapper(instance) + } + Log.w(TAG, "Class $pluginClassName is not a Plugin and has no plugin methods") + } catch (e: Exception) { + Log.e(TAG, "Failed to load manifest class $pluginClassName: ${e.message}", e) + } catch (e: Error) { + Log.e(TAG, "Linkage error loading manifest class $pluginClassName: ${e.message}", e) + } + } + + return scanForPluginClass(classLoader, cs3File) + } + + private fun readPluginClassNameFromZip(cs3File: File): String? { + return try { + ZipFile(cs3File).use { zip -> + val manifestEntry = zip.getEntry("manifest.json") ?: return null + val json = zip.getInputStream(manifestEntry).bufferedReader().readText() + val obj = JSONObject(json) + obj.optString("pluginClassName", null) + } + } catch (e: Exception) { + Log.d(TAG, "Could not read manifest.json from ZIP: ${e.message}") + null + } + } + + private fun scanForPluginClass(classLoader: DexClassLoader, cs3File: File): Plugin? { + try { + @Suppress("DEPRECATION") + val dex = dalvik.system.DexFile(cs3File) + val entries = dex.entries() + + while (entries.hasMoreElements()) { + val className = entries.nextElement() + try { + val clazz = classLoader.loadClass(className) + if (clazz.isAnnotationPresent(CloudstreamPlugin::class.java)) { + Log.d(TAG, "Found plugin class via scan: $className") + val instance = clazz.getDeclaredConstructor().newInstance() + if (instance is Plugin) { + dex.close() + return instance + } + if (looksLikePlugin(instance)) { + Log.d(TAG, "Using reflective wrapper for $className (non-standard base class)") + dex.close() + return ReflectivePluginWrapper(instance) + } + Log.w(TAG, "Annotated class $className has no plugin methods") + } + } catch (_: ClassNotFoundException) { + } catch (_: NoClassDefFoundError) { + } catch (e: Exception) { + Log.w(TAG, "Error inspecting class $className: ${e.message}") + } + } + + dex.close() + } catch (e: Exception) { + Log.d(TAG, "DexFile scan fallback failed: ${e.message}") + } + + return null + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtensionRunner.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtensionRunner.kt new file mode 100644 index 00000000..1394f4eb --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtensionRunner.kt @@ -0,0 +1,906 @@ +package com.arflix.tv.core.plugin.cloudstream + +import android.util.Log +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.MovieSearchResponse +import com.lagradost.cloudstream3.TvSeriesSearchResponse +import com.lagradost.cloudstream3.AnimeSearchResponse +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.metaproviders.TmdbLink +import com.lagradost.cloudstream3.metaproviders.TmdbProvider +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.app +import com.arflix.tv.core.plugin.TestDiagnostics +import com.arflix.tv.core.tmdb.TmdbMetadataService +import com.arflix.tv.core.tmdb.TmdbService +import com.arflix.tv.domain.model.ContentType +import com.arflix.tv.domain.model.LocalScraperResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "ExtExtensionRunner" +private const val EXECUTION_TIMEOUT_MS = 120_000L +// Per-provider loadLinks cap. Mega-aggregators (Phisher StreamPlay, Ultima, +// TorraStream) scrape many source sites in parallel and happily burn through +// minutes — cap them at 60s and return the partial link set collected so far. +private const val LOADLINKS_TIMEOUT_MS = 60_000L +private const val MIN_TITLE_SIMILARITY = 0.5 +private const val MAX_ALT_TITLES = 8 + +/** + * Executes external DEX extensions by bridging between NuvioTV's TMDB ID-based system + * and the extensions' text search-based API. + * + * Flow: TMDB ID → title lookup → search() → match → load() → loadLinks() → LocalScraperResult + */ +@Singleton +class ExternalExtensionRunner @Inject constructor( + private val extensionLoader: ExternalExtensionLoader, + private val extractorRegistry: ExternalExtractorRegistry, + private val tmdbMetadataService: TmdbMetadataService, + private val tmdbService: TmdbService +) { + suspend fun execute( + scraperId: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List = withContext(Dispatchers.IO) { + extensionLoader.ensureExtractorsLoaded(listOf(scraperId)) + + val api = extensionLoader.getApi(scraperId) + if (api == null) { + Log.e(TAG, "No API loaded for scraper: $scraperId") + return@withContext emptyList() + } + + try { + executeInternal(api, tmdbId, mediaType, season, episode) + } catch (e: Exception) { + Log.e(TAG, "Extension ${api.name} failed: ${e.javaClass.simpleName}: ${e.message}", e) + emptyList() + } catch (e: Error) { + val missing = extractMissingClass(e) + if (missing != null) { + Log.e(TAG, "Extension ${api.name} MISSING CLASS: $missing", e) + } else { + Log.e(TAG, "Extension ${api.name} linkage error: ${e.javaClass.simpleName}: ${e.message}", e) + } + emptyList() + } + } + + suspend fun executeWithDiagnostics( + scraperId: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + diagnostics: TestDiagnostics + ): List = withContext(Dispatchers.IO) { + extensionLoader.ensureExtractorsLoaded(listOf(scraperId), diagnostics) + + diagnostics.addStep("Loading DEX extension...") + val apis = extensionLoader.loadExtensionWithDiagnostics(scraperId, diagnostics) + val api = apis.firstOrNull() + + if (api == null) { + diagnostics.addStep("No MainAPI available after load") + return@withContext emptyList() + } + + diagnostics.addStep("Using MainAPI: ${api.name} (${api.javaClass.simpleName})") + val isTmdb = api is TmdbProvider + diagnostics.addStep("Provider type: ${if (isTmdb) "TmdbProvider" else "search-based"}") + + withTimeoutOrNull(EXECUTION_TIMEOUT_MS) { + try { + if (isTmdb) { + executeTmdbProviderWithDiagnostics(api, tmdbId, mediaType, season, episode, diagnostics) + } else { + executeSearchBasedWithDiagnostics(api, tmdbId, mediaType, season, episode, diagnostics) + } + } catch (e: Error) { + Log.e(TAG, "Diagnostic ${api.name} error: ${e.javaClass.simpleName}: ${e.message}", e) + diagnostics.addStep("Runtime error: ${e.javaClass.simpleName}") + diagnostics.addStep("Detail: ${e.message?.take(300)}") + emptyList() + } catch (e: Exception) { + Log.e(TAG, "Diagnostic ${api.name} exception: ${e.javaClass.simpleName}: ${e.message}", e) + diagnostics.addStep("Runtime exception: ${e.javaClass.simpleName}: ${e.message?.take(300)}") + emptyList() + } + } ?: run { + diagnostics.addStep("TIMEOUT after ${EXECUTION_TIMEOUT_MS}ms") + emptyList() + } + } + + private suspend fun executeTmdbProviderWithDiagnostics( + api: MainAPI, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + diagnostics: TestDiagnostics + ): List { + val tmdbIdInt = tmdbId.toIntOrNull() + val contentType = when (mediaType.lowercase()) { + "movie" -> ContentType.MOVIE + else -> ContentType.SERIES + } + + diagnostics.addStep("Fetching TMDB metadata...") + val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType) + val movieName = enrichment?.localizedTitle + diagnostics.addStep("TMDB title: ${movieName ?: "(null)"}") + + val imdbId = if (tmdbIdInt != null) tmdbService.tmdbToImdb(tmdbIdInt, mediaType) else null + diagnostics.addStep("IMDB ID: ${imdbId ?: "(not found)"}") + + val tmdbLink = TmdbLink( + imdbID = imdbId, + tmdbID = tmdbIdInt, + episode = episode, + season = season, + movieName = movieName + ) + val data = tmdbLink.toJson() + diagnostics.addStep("TmdbLink JSON: ${data.take(120)}") + + diagnostics.addStep("Calling loadLinks()...") + val links = mutableListOf() + val subtitles = mutableListOf() + + // Instrument loadExtractor to log each call's result + data class ExtractorCall(val url: String, var matched: Boolean = false, var linkCount: Int = 0, var error: String? = null) + val extractorCalls = mutableListOf() + + val success = try { + api.loadLinks( + data = data, + isCasting = false, + subtitleCallback = { subtitles.add(it) }, + callback = { links.add(it) } + ) + } catch (e: Throwable) { + diagnostics.addStep("loadLinks THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}") + false + } + + diagnostics.addStep("loadLinks returned: success=$success, ${links.size} links, ${subtitles.size} subs") + + // Show missing extractor domains + val missing = extractorRegistry.getMissingExtractorDomains() + if (missing.isNotEmpty()) { + diagnostics.addStep("Missing extractors: ${missing.take(5).joinToString()}") + } + + return links.filterValid().map { it.toLocalScraperResult(api.name) } + } + + private suspend fun executeSearchBasedWithDiagnostics( + api: MainAPI, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + diagnostics: TestDiagnostics + ): List { + val contentType = when (mediaType.lowercase()) { + "movie" -> ContentType.MOVIE + else -> ContentType.SERIES + } + + diagnostics.addStep("Fetching TMDB metadata...") + val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType) + if (enrichment == null) { + diagnostics.addStep("TMDB enrichment FAILED") + return emptyList() + } + + val title = enrichment.localizedTitle + if (title == null) { + diagnostics.addStep("TMDB returned no title") + return emptyList() + } + val year = enrichment.releaseInfo?.take(4)?.toIntOrNull() + diagnostics.addStep("TMDB: \"$title\" ($year)") + + // Check if search() is actually overridden + val searchMethod = try { + api.javaClass.getMethod("search", String::class.java, kotlin.coroutines.Continuation::class.java) + } catch (_: Exception) { null } + val declaringClass = searchMethod?.declaringClass?.name ?: "unknown" + diagnostics.addStep("search() declared in: $declaringClass") + + // Install temporary HTTP logging on the app singleton + val httpLog = mutableListOf() + val originalClient = app.baseClient + val loggingClient = originalClient.newBuilder() + .addInterceptor { chain -> + val req = chain.request() + httpLog.add("→ ${req.method} ${req.url}") + try { + val resp = chain.proceed(req) + httpLog.add("← ${resp.code} (${resp.body?.contentLength() ?: "?"} bytes)") + resp + } catch (e: Exception) { + httpLog.add("← FAILED: ${e.javaClass.simpleName}: ${e.message?.take(80)}") + throw e + } + } + .build() + app.baseClient = loggingClient + + diagnostics.addStep("Searching for: \"$title\"") + var searchResults = try { + api.search(title, 1)?.items + } catch (e: Exception) { + diagnostics.addStep("search() THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}") + null + } catch (e: Error) { + val missingCls = extractMissingClass(e) + diagnostics.addStep("search() ERROR: ${missingCls ?: e.message?.take(120)}") + null + } finally { + app.baseClient = originalClient + } + + // Show HTTP activity + if (httpLog.isEmpty()) { + diagnostics.addStep("HTTP: no requests made by search()") + } else { + diagnostics.addStep("HTTP: ${httpLog.size / 2} request(s)") + httpLog.take(6).forEach { diagnostics.addStep(" $it") } + if (httpLog.size > 6) diagnostics.addStep(" ... and ${httpLog.size - 6} more") + } + + diagnostics.addStep("Search returned: ${if (searchResults == null) "null" else "${searchResults.size} results"}") + + // Fallback: if title has special characters, try simplified version + if (searchResults.isNullOrEmpty() && title.contains(Regex("[:\\-–—]"))) { + val simplified = title.replace(Regex("[:\\-–—]"), " ").replace(Regex("\\s+"), " ").trim() + diagnostics.addStep("Retrying with: \"$simplified\"") + searchResults = try { + api.search(simplified, 1)?.items + } catch (e: Exception) { + diagnostics.addStep("search(simplified) THREW: ${e.javaClass.simpleName}: ${e.message?.take(120)}") + null + } catch (e: Error) { + null + } + diagnostics.addStep("Retry returned: ${if (searchResults == null) "null" else "${searchResults.size} results"}") + } + + if (searchResults.isNullOrEmpty()) return emptyList() + + val bestMatch = findBestMatch(searchResults, listOf(title), year, mediaType) + if (bestMatch == null) { + diagnostics.addStep("No match above similarity threshold ($MIN_TITLE_SIMILARITY)") + searchResults.take(3).forEachIndexed { i, r -> + val sim = calculateSimilarity(r.name, title) + diagnostics.addStep(" [$i] \"${r.name}\" (sim=${String.format("%.2f", sim)})") + } + return emptyList() + } + diagnostics.addStep("Best match: \"${bestMatch.name}\" (${bestMatch.url.take(80)})") + + diagnostics.addStep("Loading page...") + val loadResponse = api.load(bestMatch.url) + if (loadResponse == null) { + diagnostics.addStep("load() returned null") + return emptyList() + } + diagnostics.addStep("Loaded: ${loadResponse.javaClass.simpleName}") + + val data = extractData(loadResponse, mediaType, season, episode) + if (data == null) { + diagnostics.addStep("No episode data for S${season}E${episode}") + return emptyList() + } + + diagnostics.addStep("Calling loadLinks()...") + val links = mutableListOf() + val subtitles = mutableListOf() + + val success = api.loadLinks( + data = data, + isCasting = false, + subtitleCallback = { subtitles.add(it) }, + callback = { links.add(it) } + ) + + diagnostics.addStep("loadLinks returned: success=$success, ${links.size} links, ${subtitles.size} subs") + return links.filterValid().map { it.toLocalScraperResult(api.name) } + } + + private fun extractMissingClass(e: Error): String? { + val msg = e.message ?: return null + val match = Regex("""(?:L?)([\w/.]+)(?:;)?""").find(msg) + return match?.groupValues?.get(1)?.replace('/', '.') + } + + private suspend fun executeInternal( + api: MainAPI, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List { + if (api is TmdbProvider) { + return executeTmdbProvider(api, tmdbId, mediaType, season, episode) + } + return executeSearchBased(api, tmdbId, mediaType, season, episode) + } + + /** + * Execute a TmdbProvider extension using the same flow as CloudStream: + * 1. Construct the JSON that the extension's load() expects + * 2. Call api.load(json) → extension fetches metadata, constructs its internal LinkData + * 3. Extract the data string from the LoadResponse + * 4. Call api.loadLinks(data, ...) → extension resolves streams + * + * TmdbProvider extensions (StreamPlay, Ultima, etc.) override load() to accept + * JSON like {"id":803796,"type":"movie"}, NOT a TMDB URL. They then construct + * their own internal data classes (LinkData) with fields like "id", "imdbId", + * "title" etc. that differ from TmdbLink's field names. + */ + private suspend fun executeTmdbProvider( + api: MainAPI, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List { + val tmdbIdInt = tmdbId.toIntOrNull() + val isMovie = mediaType.lowercase() == "movie" + val type = if (isMovie) "movie" else "tv" + + // Construct the JSON that TmdbProvider extensions expect in load() + // This matches what their search() returns as URLs + val loadJson = """{"id":$tmdbIdInt,"type":"$type"}""" + + Log.d(TAG, "TmdbProvider ${api.name}: load($loadJson)") + val loadResponse = try { + api.load(loadJson) + } catch (e: Exception) { + Log.w(TAG, "TmdbProvider ${api.name} load(json) threw: ${e.javaClass.simpleName}: ${e.message?.take(100)}") + null + } catch (e: Error) { + val missing = extractMissingClass(e) + Log.w(TAG, "TmdbProvider ${api.name} load(json) error: ${missing ?: e.message?.take(100)}") + null + } + + if (loadResponse != null) { + Log.d(TAG, "TmdbProvider ${api.name}: loaded ${loadResponse.javaClass.simpleName}") + val data = extractData(loadResponse, mediaType, season, episode) + if (data != null) { + Log.d(TAG, "TmdbProvider ${api.name}: loadLinks data=${data.take(200)}") + return executeTmdbLoadLinks(api, data) + } + Log.w(TAG, "TmdbProvider ${api.name}: no data for S${season}E${episode}") + } + + // Fallback: try with TMDB URL format (standard TmdbProvider.load()) + val tmdbUrl = if (isMovie) { + "https://www.themoviedb.org/movie/$tmdbId" + } else { + "https://www.themoviedb.org/tv/$tmdbId" + } + Log.d(TAG, "TmdbProvider ${api.name}: fallback load($tmdbUrl)") + val fallbackResponse = try { + api.load(tmdbUrl) + } catch (e: Exception) { + Log.w(TAG, "TmdbProvider ${api.name} fallback load(url) threw: ${e.javaClass.simpleName}: ${e.message?.take(100)}") + null + } catch (e: Error) { null } + + if (fallbackResponse != null) { + val data = extractData(fallbackResponse, mediaType, season, episode) + if (data != null) { + Log.d(TAG, "TmdbProvider ${api.name}: fallback loadLinks data=${data.take(200)}") + return executeTmdbLoadLinks(api, data) + } + } + + Log.w(TAG, "TmdbProvider ${api.name}: both load() paths failed") + return emptyList() + } + + private suspend fun executeTmdbLoadLinks( + api: MainAPI, + data: String + ): List { + // Use thread-safe list so links collected during loadLinks survive timeout + val links = java.util.Collections.synchronizedList(mutableListOf()) + val subtitles = java.util.Collections.synchronizedList(mutableListOf()) + + // Wrap loadLinks in withTimeoutOrNull so the timeout cancels only this block + // (returning null locally) rather than propagating cancellation out and + // discarding the already-collected link set. This is the cancellation-scope + // trick that lets us return partial results from mega-aggregators. + val completed = withTimeoutOrNull(LOADLINKS_TIMEOUT_MS) { + try { + api.loadLinks( + data = data, + isCasting = false, + subtitleCallback = { subtitles.add(it) }, + callback = { links.add(it) } + ) + true + } catch (e: Exception) { + Log.w(TAG, "TmdbProvider ${api.name} loadLinks threw: ${e.javaClass.simpleName} (${links.size} links collected)") + false + } catch (e: Error) { + val missing = extractMissingClass(e) + Log.w(TAG, "TmdbProvider ${api.name} loadLinks error: ${missing ?: e.message} (${links.size} links collected)") + false + } + } + if (completed == null) { + Log.w(TAG, "TmdbProvider ${api.name} loadLinks timed out at ${LOADLINKS_TIMEOUT_MS}ms (${links.size} links collected so far)") + } + + if (links.isEmpty()) { + Log.w(TAG, "TmdbProvider ${api.name}: 0 links collected") + return emptyList() + } + + Log.d(TAG, "TmdbProvider ${api.name}: ${links.size} links, ${subtitles.size} subs") + return links.filterValid().map { link -> link.toLocalScraperResult(api.name) } + } + + private suspend fun executeSearchBased( + api: MainAPI, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List { + val contentType = when (mediaType.lowercase()) { + "movie" -> ContentType.MOVIE + else -> ContentType.SERIES + } + val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, contentType) + if (enrichment == null) { + Log.e(TAG, "Failed to fetch TMDB enrichment for $tmdbId") + return emptyList() + } + + val title = enrichment.localizedTitle ?: return emptyList() + val year = enrichment.releaseInfo?.take(4)?.toIntOrNull() + + // Build candidate titles for multi-language matching: primary localized title, + // TMDB original title (often non-English for foreign content), plus alt titles + // per country. Filter alts to Latin-script and cap the list — TMDB returns + // translations in every script (Cyrillic/CJK/Arabic/Thai/etc.), and trying + // each one against a Spanish/Portuguese/English provider wastes requests. + val candidateTitles = buildList { + add(title) + enrichment.originalTitle + ?.takeIf { it.isNotBlank() && !equalsIgnoreCase(it, title) } + ?.let(::add) + enrichment.alternativeTitles + .asSequence() + .filter { it.isNotBlank() && isLatinScript(it) } + .distinctBy { it.lowercase() } + .filter { alt -> none { equalsIgnoreCase(it, alt) } } + .take(MAX_ALT_TITLES) + .forEach(::add) + } + + Log.d(TAG, "SearchBased ${api.name}: searching for \"$title\" (${candidateTitles.size} candidates)") + + var outcome = trySearch(api, title) + var searchResults = outcome.items + var hostDead = outcome.hostUnreachable + var unsupported = outcome.unsupported + + if (searchResults.isNullOrEmpty() && !hostDead && !unsupported && title.contains(Regex("[:\\-–—]"))) { + val simplified = title.replace(Regex("[:\\-–—]"), " ").replace(Regex("\\s+"), " ").trim() + Log.d(TAG, "SearchBased ${api.name}: retrying with simplified \"$simplified\"") + outcome = trySearch(api, simplified) + searchResults = outcome.items + if (outcome.hostUnreachable) hostDead = true + if (outcome.unsupported) unsupported = true + } + + // Multi-title fallback: if primary found nothing AND host is reachable AND + // provider supports search, try alts in parallel. Parallel because sequential + // 7x retries × ~500ms each = ~3.5s wasted per miss; running concurrently cuts + // this to ~500ms. Picks the first non-empty result in candidate order. + if (searchResults.isNullOrEmpty() && !hostDead && !unsupported) { + val alts = candidateTitles.drop(1) + if (alts.isNotEmpty()) { + Log.d(TAG, "SearchBased ${api.name}: trying ${alts.size} alt titles in parallel") + val altOutcomes = coroutineScope { + alts.map { alt -> async { alt to trySearch(api, alt) } }.awaitAll() + } + altOutcomes.firstOrNull { it.second.hostUnreachable }?.let { hostDead = true } + altOutcomes.firstOrNull { it.second.unsupported }?.let { unsupported = true } + if (!hostDead && !unsupported) { + altOutcomes.firstOrNull { !it.second.items.isNullOrEmpty() }?.let { (alt, o) -> + Log.d(TAG, "SearchBased ${api.name}: alt title \"$alt\" returned ${o.items?.size ?: 0} results") + searchResults = o.items + } + } + } + } + + if (searchResults.isNullOrEmpty()) { + when { + hostDead -> Log.w(TAG, "SearchBased ${api.name}: host unreachable, skipping (primary=\"$title\")") + unsupported -> Log.w(TAG, "SearchBased ${api.name}: search() unsupported, skipping (primary=\"$title\")") + else -> Log.w(TAG, "SearchBased ${api.name}: 0 search results for any of ${candidateTitles.size} titles (primary=\"$title\")") + } + return emptyList() + } + Log.d(TAG, "SearchBased ${api.name}: ${searchResults.size} results") + + val bestMatch = findBestMatch(searchResults, candidateTitles, year, mediaType) + if (bestMatch == null) { + Log.d(TAG, "No suitable match in ${api.name} results for: $title ($year) [candidates=${candidateTitles.size}]") + searchResults.take(5).forEachIndexed { i, r -> + val sim = candidateTitles.maxOf { calculateSimilarity(r.name, it) } + Log.d(TAG, " [$i] \"${r.name}\" (sim=${String.format("%.2f", sim)}, type=${r.type})") + } + return emptyList() + } + Log.d(TAG, "Best match from ${api.name}: ${bestMatch.name} (${bestMatch.url})") + + val loadResponse = try { + api.load(bestMatch.url) + } catch (e: Exception) { + Log.e(TAG, "SearchBased ${api.name} load() threw: ${e.javaClass.simpleName}: ${e.message}", e) + null + } catch (e: Error) { + val missing = extractMissingClass(e) + Log.e(TAG, "SearchBased ${api.name} load() error: ${missing ?: e.message}", e) + null + } + if (loadResponse == null) { + Log.w(TAG, "SearchBased ${api.name}: load(${bestMatch.url}) returned null") + return emptyList() + } + Log.d(TAG, "SearchBased ${api.name}: loaded ${loadResponse.javaClass.simpleName}") + + val data = extractData(loadResponse, mediaType, season, episode) + if (data == null) { + Log.d(TAG, "No data extracted from ${api.name} for S${season}E${episode}") + return emptyList() + } + + val links = java.util.Collections.synchronizedList(mutableListOf()) + val subtitles = java.util.Collections.synchronizedList(mutableListOf()) + + val success = withTimeoutOrNull(LOADLINKS_TIMEOUT_MS) { + try { + api.loadLinks( + data = data, + isCasting = false, + subtitleCallback = { subtitles.add(it) }, + callback = { links.add(it) } + ) + } catch (e: Exception) { + Log.e(TAG, "SearchBased ${api.name} loadLinks threw: ${e.javaClass.simpleName}: ${e.message}", e) + false + } catch (e: Error) { + val missing = extractMissingClass(e) + Log.e(TAG, "SearchBased ${api.name} loadLinks error: ${missing ?: e.message}", e) + false + } + } + if (success == null) { + Log.w(TAG, "SearchBased ${api.name} loadLinks timed out at ${LOADLINKS_TIMEOUT_MS}ms (${links.size} links collected so far)") + } + + if (success != true && links.isEmpty()) { + Log.w(TAG, "SearchBased ${api.name}: loadLinks returned false/null, 0 links") + return emptyList() + } + + Log.d(TAG, "SearchBased ${api.name}: ${links.size} links, ${subtitles.size} subs") + return links.filterValid().map { link -> link.toLocalScraperResult(api.name) } + } + + /** Extract year from SearchResponse concrete types (not in the interface). */ + private fun getSearchResponseYear(result: SearchResponse): Int? = when (result) { + is MovieSearchResponse -> result.year + is TvSeriesSearchResponse -> result.year + is AnimeSearchResponse -> result.year + else -> null + } + + private fun findBestMatch( + results: List, + candidateTitles: List, + targetYear: Int?, + mediaType: String + ): SearchResponse? { + val isMovie = mediaType.lowercase() == "movie" + val movieTypes = setOf(TvType.Movie, TvType.AnimeMovie, TvType.Documentary) + val tvTypes = setOf(TvType.TvSeries, TvType.Anime, TvType.OVA, TvType.Cartoon, TvType.AsianDrama) + // Catch-all TV types: niche providers (anime/dorama/donghua sites) may list + // any result as Anime/AsianDrama/OVA, giving a free type-match against any + // TV target. Require near-exact title for these to prevent false positives + // like MundoDonghua matching "The Invincible" for the "Invincible" series. + val catchAllTvTypes = setOf(TvType.Anime, TvType.OVA, TvType.AsianDrama) + + return results + .mapNotNull { result -> + val resultType = result.type + val resultYear = getSearchResponseYear(result) + val titleSimilarity = candidateTitles.maxOf { calculateSimilarity(result.name, it) } + val isExactTitle = titleSimilarity >= 0.95 + + // Type check: hard reject only when title is NOT near-exact. Latam/es + // providers often mis-classify TV series as Movie; an exact title match + // is strong enough to override that. But a fuzzy title match ("Atom Eve" + // for "Invincible", "Hardy Boys" for "The Boys") must have matching type. + if (resultType != null && !isExactTitle) { + val typeOk = if (isMovie) resultType in movieTypes else resultType in tvTypes + if (!typeOk) return@mapNotNull null + } + // Catch-all TV types need stronger title evidence. + if (!isMovie && resultType in catchAllTvTypes && titleSimilarity < 0.9) { + return@mapNotNull null + } + // Year check: hard reject when both years known and differ by >1. + if (targetYear != null && resultYear != null && + kotlin.math.abs(targetYear - resultYear) > 1) { + return@mapNotNull null + } + + val yearBonus = if (targetYear != null && resultYear == targetYear) 0.15 else 0.0 + val typeBonus = when { + resultType == null -> 0.0 + isMovie && resultType in movieTypes -> 0.05 + !isMovie && resultType in tvTypes -> 0.05 + else -> 0.0 // type mismatch but exact title — neutral, don't boost + } + val score = titleSimilarity + yearBonus + typeBonus + result to score + } + .filter { it.second >= MIN_TITLE_SIMILARITY } + .maxByOrNull { it.second } + ?.first + } + + private data class SearchOutcome( + val items: List?, + val hostUnreachable: Boolean = false, + val unsupported: Boolean = false // provider didn't implement search() + ) + + private suspend fun trySearch(api: MainAPI, query: String): SearchOutcome = try { + SearchOutcome(api.search(query, 1)?.items) + } catch (e: java.net.UnknownHostException) { + Log.e(TAG, "SearchBased ${api.name} search(\"$query\") DNS fail: ${e.message}") + SearchOutcome(null, hostUnreachable = true) + } catch (e: NotImplementedError) { + // Provider (e.g. live-sports scrapers) doesn't override search(); retries + // with alt titles will all throw the same. Short-circuit. + Log.e(TAG, "SearchBased ${api.name}: search() not implemented; skipping provider") + SearchOutcome(null, unsupported = true) + } catch (e: Exception) { + Log.e(TAG, "SearchBased ${api.name} search(\"$query\") threw: ${e.javaClass.simpleName}: ${e.message}", e) + SearchOutcome(null) + } catch (e: Error) { + val missing = extractMissingClass(e) + Log.e(TAG, "SearchBased ${api.name} search(\"$query\") error: ${missing ?: e.message}", e) + SearchOutcome(null) + } + + private fun equalsIgnoreCase(a: String, b: String): Boolean = a.equals(b, ignoreCase = true) + + /** + * Returns true if the title is predominantly Latin-script (ASCII + Latin-1 + + * Latin Extended). Filters out TMDB alternative titles in Cyrillic, CJK, Arabic, + * Hebrew, Thai, Greek, Georgian, Korean, etc. — which waste search requests on + * providers that index Latin-script titles only (most storm-ext / latam sites). + */ + private fun isLatinScript(s: String): Boolean { + val letters = s.filter(Char::isLetter) + if (letters.isEmpty()) return true + val latinCount = letters.count { c -> + when (Character.UnicodeBlock.of(c)) { + Character.UnicodeBlock.BASIC_LATIN, + Character.UnicodeBlock.LATIN_1_SUPPLEMENT, + Character.UnicodeBlock.LATIN_EXTENDED_A, + Character.UnicodeBlock.LATIN_EXTENDED_B, + Character.UnicodeBlock.LATIN_EXTENDED_ADDITIONAL -> true + else -> false + } + } + return latinCount.toDouble() / letters.length >= 0.7 + } + + private fun extractData( + response: LoadResponse, + mediaType: String, + season: Int?, + episode: Int? + ): String? = when (response) { + is MovieLoadResponse -> response.dataUrl + is LiveStreamLoadResponse -> response.dataUrl + is TvSeriesLoadResponse -> { + findEpisode(response.episodes, season, episode)?.data + } + is AnimeLoadResponse -> { + val allEpisodes = response.episodes.values.flatten() + findEpisode(allEpisodes, season, episode)?.data + } + else -> null + } + + private fun findEpisode(episodes: List, season: Int?, episode: Int?): Episode? { + if (episodes.isEmpty()) return null + + // 1. Exact match for season and episode + if (season != null && episode != null) { + episodes.firstOrNull { it.season == season && it.episode == episode }?.let { return it } + } + + // 2. Match just the episode number (if provider doesn't use seasons, or puts everything in one list) + if (episode != null) { + episodes.firstOrNull { it.episode == episode && (it.season == null || it.season == season) } + ?.let { return it } + } + + // 3. Fallback: Check if the episode name contains the episode number + if (episode != null) { + val epStr1 = "Episode $episode" + val epStr2 = "Ep. $episode" + val epStr3 = "Ep $episode" + val epStr4 = "E$episode" + episodes.firstOrNull { ep -> + val epName = ep.name + ep.season == null && ep.episode == null && epName != null && + (epName.contains(epStr1, ignoreCase = true) || + epName.contains(epStr2, ignoreCase = true) || + epName.contains(epStr3, ignoreCase = true) || + epName.contains(epStr4, ignoreCase = true)) + }?.let { return it } + } + + // 4. Ultimate fallback: Use array index if season == 1 (or season is null) + if (episode != null && episode > 0) { + val index = episode - 1 + if (index < episodes.size) { + val candidate = episodes[index] + // Only use this fallback if the candidate doesn't explicitly declare itself as a DIFFERENT episode + if (candidate.episode == null || candidate.episode == episode) { + return candidate + } + } + } + + return null + } + + private fun calculateSimilarity(s1: String, s2: String): Double { + val a = s1.lowercase().trim() + val b = s2.lowercase().trim() + if (a == b) return 1.0 + if (a.isEmpty() || b.isEmpty()) return 0.0 + + val aNorm = normalizeTitleForMatch(a) + val bNorm = normalizeTitleForMatch(b) + if (aNorm == bNorm) return 0.95 + if (aNorm.isEmpty() || bNorm.isEmpty()) return 0.0 + + if (aNorm.contains(bNorm) || bNorm.contains(aNorm)) { + // Length-ratio-weighted containment. Prevents "Boys" ⊂ "Fantasy Boys" or + // "Invincible" ⊂ "The Boys: Diábolico Latino" from scoring 0.85 — those + // are different works. Only treat as strong match when the strings are + // close in length (trivial punctuation/articles diff). + val shortLen = minOf(aNorm.length, bNorm.length).toDouble() + val longLen = maxOf(aNorm.length, bNorm.length).toDouble() + val ratio = shortLen / longLen + if (ratio >= 0.8) return 0.85 + // else fall through to Levenshtein — naturally scores lower for + // significant length mismatches. + } + + val distance = levenshteinDistance(aNorm, bNorm) + val maxLen = maxOf(aNorm.length, bNorm.length) + return 1.0 - (distance.toDouble() / maxLen) + } + + /** + * Strip noise that provider titles commonly add: release year, season/part + * markers, language/dub tags. Lets "Invincible S4 Castellano" normalize to + * "Invincible" so it matches the TMDB primary title cleanly. + */ + private fun normalizeTitleForMatch(lowered: String): String { + return lowered + .replace(Regex("\\(\\d{4}\\)"), " ") + .replace(Regex("\\b\\d{4}\\b"), " ") // bare year + .replace(Regex("\\b(temporada|season)\\s*\\d+\\b"), " ") + .replace(Regex("\\b[st]\\d{1,2}\\b"), " ") // s1, t2, s04 + .replace(Regex("\\b(part|parte)\\s*\\d+\\b"), " ") + .replace(Regex("\\b(latino|castellano|subtitulado|sub\\s*espa(ñ|n)ol|espa(ñ|n)ol|dual|vose|vostfr|subbed|dubbed)\\b"), " ") + .replace(Regex("[:\\-–—]"), " ") + .replace(Regex("\\s+"), " ") + .trim() + } + + private fun levenshteinDistance(s1: String, s2: String): Int { + val m = s1.length + val n = s2.length + val dp = Array(m + 1) { IntArray(n + 1) } + + for (i in 0..m) dp[i][0] = i + for (j in 0..n) dp[0][j] = j + + for (i in 1..m) { + for (j in 1..n) { + val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1 + dp[i][j] = minOf( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost + ) + } + } + return dp[m][n] + } + + /** Filter out broken ExtractorLinks (invalid URLs, error strings, etc.) */ + private fun List.filterValid(): List { + return filter { link -> + val url = link.url + when { + url.isBlank() -> false + url == "error" || url == "null" -> false + !url.startsWith("http://") && !url.startsWith("https://") -> false + else -> true + }.also { valid -> + if (!valid) Log.w(TAG, "Filtered invalid link: source=${link.source}, url=${url.take(60)}") + } + } + } + + private fun ExtractorLink.toLocalScraperResult(providerName: String): LocalScraperResult { + val qualityStr = Qualities.getStringByInt(quality).ifEmpty { null } + val streamType = when (type) { + ExtractorLinkType.M3U8 -> "hls" + ExtractorLinkType.DASH -> "dash" + else -> null + } + val allHeaders = buildMap { + putAll(headers) + if (referer.isNotBlank()) put("Referer", referer) + } + + return LocalScraperResult( + title = name, + name = source, + url = url, + quality = qualityStr, + type = streamType, + headers = allHeaders.ifEmpty { null }, + provider = providerName + ) + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtractorRegistry.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtractorRegistry.kt new file mode 100644 index 00000000..8beec957 --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalExtractorRegistry.kt @@ -0,0 +1,85 @@ +package com.arflix.tv.core.plugin.cloudstream + +import android.util.Log +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.extractorApis +import com.lagradost.cloudstream3.utils.loadExtractor +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "ExtExtractorRegistry" + +/** + * Registry of loaded extractors from external extensions. + * Bridges NuvioTV's extractor management with the CloudStream library's + * global [extractorApis] list and [loadExtractor] function. + */ +@Singleton +class ExternalExtractorRegistry @Inject constructor() { + + private val missingExtractorDomains = mutableSetOf() + private var installed = false + + fun registerExtractor(extractor: ExtractorApi) { + // Avoid duplicates by mainUrl + if (extractorApis.any { it.mainUrl == extractor.mainUrl }) return + extractorApis.add(extractor) + Log.d(TAG, "Registered extractor: ${extractor.name} (${extractor.mainUrl})") + } + + fun registerAll(extractorList: List) { + extractorList.forEach { registerExtractor(it) } + } + + fun clear() { + missingExtractorDomains.clear() + } + + /** + * Try to resolve a URL using the library's loadExtractor. + * The library's loadExtractor iterates through the global extractorApis list + * which includes both built-in library extractors and extension-provided ones. + */ + suspend fun resolveExtractor( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + return try { + val result = loadExtractor(url, referer, subtitleCallback, callback) + if (!result) { + val domain = try { + java.net.URI(url).host ?: url + } catch (_: Exception) { + url + } + if (missingExtractorDomains.add(domain)) { + Log.w(TAG, "No extractor registered for domain: $domain (url: $url)") + } + } + result + } catch (e: Exception) { + Log.e(TAG, "loadExtractor error for ${url.take(80)}: ${e.message}", e) + false + } catch (e: Error) { + Log.e(TAG, "loadExtractor linkage error for ${url.take(80)}: ${e.message}", e) + false + } + } + + /** + * Install this registry. The library's loadExtractor function uses the global + * extractorApis list directly, so no delegate setup is needed. + * This method ensures the library's built-in extractors are available. + */ + fun installGlobal() { + if (installed) return + installed = true + Log.d(TAG, "installGlobal: library extractorApis has ${extractorApis.size} built-in extractors") + } + + fun getMissingExtractorDomains(): Set = missingExtractorDomains.toSet() +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalRepoParser.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalRepoParser.kt new file mode 100644 index 00000000..c383cc75 --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalRepoParser.kt @@ -0,0 +1,147 @@ +package com.arflix.tv.core.plugin.cloudstream + +import android.util.Log +import com.arflix.tv.domain.model.ExternalPluginEntry +import com.arflix.tv.domain.model.ExternalRepoManifest +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "ExternalRepoParser" + +/** + * Result of parsing an external repository URL. + */ +data class ExternalRepoParseResult( + val name: String, + val description: String?, + val plugins: List +) + +/** + * Parses external extension repository formats. + * + * Supports two formats: + * 1. Repo manifest with `pluginLists` URLs pointing to separate plugins.json files + * 2. Direct plugins array (list of [ExternalPluginEntry]) + */ +@Singleton +class ExternalRepoParser @Inject constructor( + private val moshi: Moshi +) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .followRedirects(true) + .build() + + private val repoManifestAdapter = moshi.adapter(ExternalRepoManifest::class.java) + private val pluginListType = Types.newParameterizedType(List::class.java, ExternalPluginEntry::class.java) + private val pluginListAdapter = moshi.adapter>(pluginListType) + + /** + * Try to parse the given URL as an external repository. + * Returns null if the content doesn't match any known external format. + */ + suspend fun tryParse(url: String, fallbackName: String? = null): ExternalRepoParseResult? = withContext(Dispatchers.IO) { + val body = fetchBody(url) ?: return@withContext null + val trimmed = body.trim() + + // Try as repo manifest (has "pluginLists" key) + if (trimmed.contains("\"pluginLists\"")) { + try { + val manifest = repoManifestAdapter.fromJson(trimmed) + if (manifest != null && manifest.pluginLists.isNotEmpty()) { + Log.d(TAG, "Parsed as repo manifest: ${manifest.name}, ${manifest.pluginLists.size} plugin lists") + val allPlugins = coroutineScope { + manifest.pluginLists.map { listUrl -> + async { + val resolvedUrl = resolveUrl(url, listUrl) + fetchPluginList(resolvedUrl) ?: emptyList() + } + }.awaitAll().flatten() + } + return@withContext ExternalRepoParseResult( + name = manifest.name, + description = manifest.description, + plugins = allPlugins + ) + } + } catch (e: Exception) { + Log.d(TAG, "Not a repo manifest: ${e.message}") + } + } + + // Try as direct plugins array (has "internalName" or "tvTypes") + if (trimmed.startsWith("[")) { + try { + val plugins = pluginListAdapter.fromJson(trimmed) + if (!plugins.isNullOrEmpty() && plugins.first().internalName.isNotBlank()) { + Log.d(TAG, "Parsed as direct plugins list: ${plugins.size} plugins") + val repoName = fallbackName ?: inferRepoName(url) + return@withContext ExternalRepoParseResult( + name = repoName, + description = null, + plugins = plugins + ) + } + } catch (e: Exception) { + Log.d(TAG, "Not a direct plugins list: ${e.message}") + } + } + + null + } + + private suspend fun fetchPluginList(url: String): List? = withContext(Dispatchers.IO) { + val body = fetchBody(url) ?: return@withContext null + try { + pluginListAdapter.fromJson(body.trim()) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse plugin list from $url: ${e.message}") + null + } + } + + private fun fetchBody(url: String): String? { + return try { + val request = Request.Builder() + .url(url) + .header("User-Agent", "NuvioTV/1.0") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.e(TAG, "HTTP ${response.code} for $url") + return null + } + response.body?.string() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch $url: ${e.message}") + null + } + } + + private fun resolveUrl(baseUrl: String, relativeUrl: String): String { + if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://")) { + return relativeUrl + } + val base = baseUrl.substringBeforeLast("/") + return "$base/$relativeUrl" + } + + private fun inferRepoName(url: String): String { + // Try to extract a meaningful name from the URL + val path = url.substringAfter("://").substringBefore("?") + val segments = path.split("/").filter { it.isNotBlank() } + return segments.lastOrNull()?.removeSuffix(".json") ?: "External Repository" + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/TvTypeExtensions.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/TvTypeExtensions.kt new file mode 100644 index 00000000..2aed5f77 --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/TvTypeExtensions.kt @@ -0,0 +1,14 @@ +package com.arflix.tv.core.plugin.cloudstream + +import com.lagradost.cloudstream3.TvType + +/** Map CloudStream TvType to NuvioTV content type string ("movie" or "tv"). */ +fun TvType.toNuvioType(): String = when (this) { + TvType.Movie, TvType.AnimeMovie, TvType.Documentary, TvType.Torrent -> "movie" + else -> "tv" +} + +/** Parse TvType from string name, case-insensitive. */ +fun tvTypeFromString(value: String): TvType? = TvType.entries.firstOrNull { + it.name.equals(value, ignoreCase = true) +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/runtime/PluginRuntimeHooks.kt b/app/src/sideload/kotlin/com/arflix/tv/core/runtime/PluginRuntimeHooks.kt new file mode 100644 index 00000000..78f84296 --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/runtime/PluginRuntimeHooks.kt @@ -0,0 +1,77 @@ +package com.arflix.tv.core.runtime + +import android.app.Activity +import android.app.Application +import android.os.Build +import android.util.Log +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.app +import com.lagradost.nicehttp.ignoreAllSSLErrors + +import okhttp3.Cache +import okhttp3.OkHttpClient +import org.conscrypt.Conscrypt +import java.io.File +import java.security.Security + +object PluginRuntimeHooks { + @Volatile private var application: Application? = null + + fun onApplicationCreate(application: Application) { + // Defer heavy Conscrypt + baseClient init until a cloudstream extension is + // actually invoked (player launch / source/plugin screens). On cold start + // for users who never open those screens, this saves ~50-200ms of native + // crypto provider setup on the main thread. + this.application = application + AcraApplication.context = application + } + + @Volatile + private var isCloudstreamInitialized = false + + /** + * Lazily initialize Conscrypt + the cloudstream baseClient. Safe to call + * repeatedly; only the first call performs work. Must be invoked before any + * cloudstream extension code runs (loadExtension / downloadExtension / + * extension test runners / CloudflareKiller). + */ + fun ensureCloudstreamInitialized() { + if (isCloudstreamInitialized) return + + synchronized(this) { + if (isCloudstreamInitialized) return + val currentApp = application ?: return + + try { + Security.insertProviderAt(Conscrypt.newProvider(), 1) + } catch (e: Exception) { + Log.w("NuvioApplication", "Failed to install Conscrypt: ${e.message}") + } + + try { + app.baseClient = OkHttpClient.Builder() + .cookieJar(okhttp3.CookieJar.NO_COOKIES) + .followRedirects(true) + .followSslRedirects(true) + .ignoreAllSSLErrors() + .cache(Cache( + directory = File(currentApp.cacheDir, "http_cache"), + maxSize = 50L * 1024L * 1024L + )) + .build() + } catch (e: Throwable) { + Log.w("NuvioApplication", "Failed to initialize NiceHttp client (API ${Build.VERSION.SDK_INT}): ${e.message}") + } + + isCloudstreamInitialized = true + } + } + + fun onActivityCreate(activity: Activity) { + AcraApplication.setActivity(activity) + } + + fun onActivityDestroy() { + AcraApplication.setActivity(null) + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/tmdb/TmdbMetadataService.kt b/app/src/sideload/kotlin/com/arflix/tv/core/tmdb/TmdbMetadataService.kt new file mode 100644 index 00000000..a89649ba --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/tmdb/TmdbMetadataService.kt @@ -0,0 +1,19 @@ +package com.arflix.tv.core.tmdb + +import com.arflix.tv.domain.model.ContentType +import javax.inject.Inject +import javax.inject.Singleton + +data class TmdbEnrichment( + val localizedTitle: String?, + val releaseInfo: String?, + val originalTitle: String?, + val alternativeTitles: List +) + +@Singleton +class TmdbMetadataService @Inject constructor() { + suspend fun fetchEnrichment(tmdbId: String, contentType: ContentType): TmdbEnrichment? { + return null + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/tmdb/TmdbService.kt b/app/src/sideload/kotlin/com/arflix/tv/core/tmdb/TmdbService.kt new file mode 100644 index 00000000..639a3f92 --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/core/tmdb/TmdbService.kt @@ -0,0 +1,11 @@ +package com.arflix.tv.core.tmdb + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TmdbService @Inject constructor() { + suspend fun tmdbToImdb(tmdbId: Int, mediaType: String): String? { + return null + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/data/local/ProfileDataStoreFactory.kt b/app/src/sideload/kotlin/com/arflix/tv/data/local/ProfileDataStoreFactory.kt new file mode 100644 index 00000000..94a6033b --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/data/local/ProfileDataStoreFactory.kt @@ -0,0 +1,20 @@ +package com.arflix.tv.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.pluginDataStore: DataStore by preferencesDataStore(name = "plugin_settings") + +@Singleton +class ProfileDataStoreFactory @Inject constructor( + @ApplicationContext private val context: Context +) { + fun get(profileId: Int, feature: String): DataStore { + return context.pluginDataStore + } +} diff --git a/app/src/sideload/kotlin/com/arflix/tv/domain/model/ContentType.kt b/app/src/sideload/kotlin/com/arflix/tv/domain/model/ContentType.kt new file mode 100644 index 00000000..2c74bfde --- /dev/null +++ b/app/src/sideload/kotlin/com/arflix/tv/domain/model/ContentType.kt @@ -0,0 +1,6 @@ +package com.arflix.tv.domain.model + +enum class ContentType { + MOVIE, + SERIES +} diff --git a/app/src/sideload/kotlin/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/sideload/kotlin/com/lagradost/cloudstream3/AcraApplication.kt new file mode 100644 index 00000000..70a7a1f6 --- /dev/null +++ b/app/src/sideload/kotlin/com/lagradost/cloudstream3/AcraApplication.kt @@ -0,0 +1,20 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.content.Context +import java.lang.ref.WeakReference + +object AcraApplication { + var context: Context? = null + + private var currentActivity: WeakReference? = null + + fun setActivity(activity: Activity?) { + currentActivity = if (activity != null) WeakReference(activity) else null + } + + fun getActivity(): Activity? { + return currentActivity?.get() + } +} + diff --git a/app/src/sideload/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/sideload/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt new file mode 100644 index 00000000..47057e2e --- /dev/null +++ b/app/src/sideload/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -0,0 +1,66 @@ +package com.lagradost.cloudstream3.plugins + +import android.app.Activity +import android.content.Context +import android.util.Log +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.extractorApis + +/** + * The base class that CloudStream extensions extend in NuvioTV. + * Kept standalone (not extending BasePlugin) because BasePlugin's registration + * methods are final and can't be overridden. Extensions compiled against the + * real CloudStream app reference this class directly. + */ +open class Plugin { + private val _registeredMainAPIs = mutableListOf() + private val _registeredExtractorAPIs = mutableListOf() + + val registeredMainAPIs: List get() = _registeredMainAPIs + val registeredExtractorAPIs: List get() = _registeredExtractorAPIs + + /** Extensions can set this to provide a settings UI callback. No-op in NuvioTV. */ + var openSettings: ((Context) -> Unit)? = null + + /** Full file path to the plugin (matches BasePlugin's property). */ + var filename: String? = null + + /** + * No-arg load matching BasePlugin's pattern. Extensions compiled against + * CloudStream may override this instead of load(Activity?). + */ + open fun load() {} + + /** + * Called when the plugin is loaded. Override to register APIs. + * The [activity] parameter may be null when loaded outside an Activity context. + * Delegates to no-arg load() so BasePlugin-style extensions get invoked. + */ + @Suppress("UNUSED_PARAMETER") + open fun load(activity: Activity?) { + load() + } + + fun registerMainAPI(element: MainAPI) { + Log.d("CS3Plugin", "registerMainAPI called: ${element.name} (${element.javaClass.name})") + _registeredMainAPIs.add(element) + // Also register globally for extensions that access APIHolder directly + element.sourcePlugin = this.filename + try { + com.lagradost.cloudstream3.APIHolder.addPluginMapping(element) + } catch (_: Exception) {} + } + + fun registerExtractorAPI(element: ExtractorApi) { + Log.d("CS3Plugin", "registerExtractorAPI called: ${element.name} (${element.javaClass.name})") + _registeredExtractorAPIs.add(element) + element.sourcePlugin = this.filename + extractorApis.add(element) + } + + // Some extensions call these overloads + open fun load(context: Context) { + load(context as? Activity) + } +} diff --git a/app/src/sideload/kotlin/com/lagradost/cloudstream3/plugins/PluginManagerStub.kt b/app/src/sideload/kotlin/com/lagradost/cloudstream3/plugins/PluginManagerStub.kt new file mode 100644 index 00000000..fe559495 --- /dev/null +++ b/app/src/sideload/kotlin/com/lagradost/cloudstream3/plugins/PluginManagerStub.kt @@ -0,0 +1,16 @@ +@file:Suppress("unused") + +package com.lagradost.cloudstream3.plugins + +/** Stub for CloudStream PluginManager referenced by some extensions. */ +object PluginManager { + data class PluginData( + val name: String = "", + val url: String = "", + val internalName: String = "", + val version: Int = 0 + ) + + fun getPluginsOnline(): Array = emptyArray() + fun unloadPlugin(filePath: String) {} +} diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts index 47461fd8..1995572e 100644 --- a/benchmark/build.gradle.kts +++ b/benchmark/build.gradle.kts @@ -22,9 +22,9 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } + + + buildTypes { create("benchmark") { @@ -59,3 +59,8 @@ dependencies { implementation("androidx.test:rules:1.5.0") implementation("androidx.test.uiautomator:uiautomator:2.2.0") } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index f421fea8..a52ab740 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,13 +4,13 @@ plugins { id("com.android.application") version "8.7.3" apply false id("com.android.test") version "8.5.2" apply false id("androidx.baselineprofile") version "1.3.1" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.3.0" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0" apply false // Kotlin 2.0+: Compose compiler is a dedicated Gradle plugin; version // must track Kotlin. - id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false - id("com.google.devtools.ksp") version "2.1.0-1.0.29" apply false - id("com.google.dagger.hilt.android") version "2.54" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.0" apply false + id("com.google.devtools.ksp") version "2.3.0" apply false + id("com.google.dagger.hilt.android") version "2.57" apply false id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version "2.0.1" apply false // Firebase - requires google-services.json from Firebase Console id("com.google.gms.google-services") version "4.4.0" apply false @@ -18,3 +18,8 @@ plugins { // Static analysis id("io.gitlab.arturbosch.detekt") version "1.23.7" apply false } + + + + + diff --git a/gradle.properties b/gradle.properties index d2b77715..cf07c7c3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,3 +14,5 @@ kotlin.code.style=official android.enableJetifier=false + +hilt.enableAggregatingTask=false From 9f1138bab88751b0e7e62c4ae20330d0eeeabd03 Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Thu, 4 Jun 2026 21:10:45 +0300 Subject: [PATCH 2/4] chore: Include quickjs-kt AAR --- app/libs/quickjs-kt-android-1.0.5-nuvio.aar | Bin 0 -> 1882414 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/libs/quickjs-kt-android-1.0.5-nuvio.aar diff --git a/app/libs/quickjs-kt-android-1.0.5-nuvio.aar b/app/libs/quickjs-kt-android-1.0.5-nuvio.aar new file mode 100644 index 0000000000000000000000000000000000000000..565df23bce8d600fb5faddeb6f4a646a48d54c09 GIT binary patch literal 1882414 zcmV)IK)k@6aWAS z2mk;8K>*HE3&fTH007JY000vJ002R5WO8q5WKCgiX=Y_}bS`*pY&DL{4uc>NMfWED z1DUG*=T88EZ zeOJkV(G8Xg+w@2+Mw62>3Z_!#RtVCQ4M=?FT&Ey#_99ynJA=An+h)`rN2_2{xJe!} zUsk96lYn(~^_S#ImKrji?dLYQcsKrWbY8B~#vNb9~(O9KQH000OG0000%0Lh|= zn5_{404hQP01E&B0Ap-nb8}^LE^1+Nl)7V(C-KrH+%s+4wr$(CZQGjGzir#LZClf} zyQgh;zw_X}vG<9)cXunIK2%1Y4^@%pLjRXYR~Jj0{~rYLpCGUW*h7cE(4fCDx_=20 z^>8qCva~g|b1}40{EwKFv!H_|ow1FfvvahPtTM77f^Q=s2(gfW$UtaS*sn580a|z+ z7z~%{ID;@P6mV+^Esch?Xzomi*3A#I(hDfXDhX6|2yR30zhRhd+lQ3%=FYdgV5Qk^&G#aHi5)H4jhK``d)7 z10Y5}7gJjo?mrX&G1r=F&}jlX1}ysb!Yj8`NcD;W6{`@o_NoMms_~4Mb5#Sv@-YpICT2efBw?QO3RTc(j)nBCTHgxc$f7_kzRlLom$YOoS3kNk>{ z4?>VrUrW10^gTJVcJ|N@wq2A}sh38AeN*Cad7UNg{t_)dE9E$~e@?>(i-wY534Fxa@5cg*21%O`;X;hdm%+agDeH1b(yZ@-oNZM5kb= zrndLXai_D4L>0wnNVu*0lIKR(%XtrLAX#TWji>4fpR0kO-G6sZ>j1sO=7ev9A10{q z9NqHU%l>=@6)SiF!MoGBD~(KzVVn9b6yUdUPod#ntTcoyZ}&c(Cxjg{Idi6zNUm7g z8sqe&(~-pkO+Lf1Vf{HW<9L0#XXf>Y&7^glc6bdw*5{Qn65(e`vxk@qJLDMM{$qeZ z3B!n^3p&UFmzZm_@08LTnHXd720e464GuZ&@S|LJ$Y!&F(ROgi7Mr=v*ci>-oe<@i zVYX`^aDX>Nl*eqv@Jlh!`c%0skYtbQ&Hv{=*X{`uc8Mo65RfG<5D@XdtX)}CTYD!@ zRcAwU)Bh@1-_$jAoKe(~UaS_wRY3I`D3Va1*w28t_~1CfF$n_%3=lPv4JBY&j5JVL z)}e+*iWp<^m`3hxq>b=I4!MFXO$UqIxoDV81Dw$4TE zuKud}?$gz^mDYS;hl8;|FbC`~ww38B-&$sq6Hhzql2vxLcVC*^$>ZC4`^z1=KSvOD zn%t_tijwuNbG6r+)U%OL7*KL0i;!nDJA|GeO1~aym{DAwX;KRzNEuOxCKOT{62_UrJMwp=KMfo=Ub1lms_ew;i!DFY6$AFe1zsh%Um*joXE53 zwOui-pmb{&81>i<3Fc}c@MFS?>+bz`(G z{U=iPI$a_<*KMn?XRv9O4d#*V{w*Y*ZtS^)0kLg(`>j%2=8Y$xjun$7Xy7D}&Y%p| zz;(yyeK{tXMAi3M#(^9AtbM}4fh)o`B!oPI zX$Fv8%<6YT^?+cn;Uksyj*h-UyFyE#p_IsU>cvY*WsUl zcY2=nNw^-nPU*-VeeG6kyJKETA8@sV4jGYcjO(USE zCByM9!r2*`Wb6vIS4BP8fbq?BmGHQ+6Z+c1;wtosZ3#}VjuM6@JvOh_j1h$wBx%+b z=tjI%B;XT>olUWr^CZ1<5o$UG#nQw!0L^^9!if;YBCePC*v`iIj2x0Sjz47>W{LNEb>vyV;Zzf-R~RM2jdZ1%XRI(Tk72}}bfHmDaEw|4Bk)FSH~$*>9EFFc}{&p5ZD~oV4m`kPp)O zqfUQ7i=T(gFdH9ELgP9#2noy2LuUYtA19%19$sEz^)M7(a`hr~5UJ&5PZCo5^G2oX z;Ol+(U_Tt&WZ$rALA>HzliN~TCyS29zNm-RYYVl4-E;}&vOm>LiQ4U{!DBqa-P&wP zicWhic#O45!=-AeTGLwl1RUMMTej$;{J}1e9Aa8qliHNq&CTaJWNZ!z=lDFD*37M; z^2tO=g1|HIHskj%rAFRRC})2-$3=>X{ACs?CkB~9mQb=qipl&X7AhwT_UH;6xHK_W zINcJ(OyO3`-zRg{Sg@QA>0-)oz$NnugK?HkM+`PtE_;h5g6epz(K9$BGKJKkkc%8y zgGH7PhYY4z-JDZtBl9@NGKKu1poPe0BAr&E+Y)U1G?~I6 zS84-!?lPXjFl)Gm^mG*+6Q;LZCe$F=|<}QkJkF#zI77t!VOv zhuZ6kUa2vFq$r?;#VLSZhb0e)I1qGXQTb3QYh$CJgGJScN5vL&q*3u9R@p>BNezjp zR&cad(7~h9gG6lwA4bjJUsAXY$ZKO#=|ZErf{LgV8g?sdQsF^CSqX_?7akrIbo4FrpjPqWqF@sm9#^=vP}oGEk_(9# z4cs45aO6_qK}2B>iD1s(AJ1#!RO!N{%7Kc|78-698fF%Bv@YYorpkei*bo}#QRzZJ zxrRpN&fhQ2YvWbvLZ<3LLU9ic!*sj##3F^mtDVE<5jwRuyY{A_>e_fW7ugn3?AN^o zJL?XG2vOgjBd8JB1Z{@;-O;De=OCE)Q;4SafLq~sB-||&p__fu^!>M{6DaTUqHw;Q1Og#G0cED z#%K0Ww1+HP0EjI?%om_h4J!^Q-Lau-xeOzGBb;Z_!k+a08HLNXTtAq)#3dmMM-~@q zX7`~d&M)*IhJ#JyR_|k9Cel5;Cjky*&Gv_hTJ}aBiDdQ*e`MFY$A8!_9LaWcOV+ZV z-ID=tn~Y_`cqcAyoBYm<@=S!ZXF8KD=^Bq>ProOF-ZmM})QOjz)OBBxm`uDN$$qAr ztGpzFGl*FB!7{(0Xf`{Y;*<_elWx+V-HUXg@`a4uW2Kq1qT2_)n-td|GN(DX_Q3fb zLj6cxe)4adm~f0{-Gg+4kZ(Bfh=|_f$}Cpa*#=c~`!Ty$X_RZ^e8u<3(^K+9IUwu= zFC7_z4o$bLNmHw#TAyYFJG>B%iVRJH>me(j8xLut2V7FG#%_cdk7?G39!pn^5CD&rP z+G}pV+O-ubC)l1%A zkZi^?f_&|G6pfG5$6Esgp#C#aX8iooF->N8VBAq^;l3vC5&!X|C2Y^MGuWYZOd8&^XaJl6X9RU&7ghqlMGX z(S(yDbg+J~p8Di(j^>(&p5GTpFW^OT;0E{Y`|%t2pPjq4@_PCtC=gK0-#MN7UpjX= zLl;XoQyEJmCqpMs8GAz$Q>XtPIn}n5k=0SYx28t{qyPviv1&S2Dhe{G>XsK(fwFu; zB7%srhXftNaWnQSJB7EQ2mDj((pXAza;LM-T;7gfXauS2Njl0Lri5sC!JpjgmS9CaXK? zJ5lUcoB&q$cI$*ak8QW(2Iv7|?m?%<%?6v;kF_Cmn_2~C**nx057D7G0}ibg-o?5G z=*Z+ML(hTww)APug?wuiT~x^k)hzU3CkL-2ZkH4o%I9j%-_|{w6qOuW^CgD#Pl`NL z><6itkQ38sj~Ps=SJR^o2G|J)(lz8%RB%e4&I8!47ZnaX72hU>Z|;%%9KnNFN*s6N zbFLgQOhdl#fNWeQnS7xgMU&lT$9P1antb5V^0WQ)J+GRbhj+)UKFM;9;X0PxXJLOE z4btp$yXl*kD$0iqrrbS8-{m?A+-&a-cKU|`TVA$fs~CziB<$|{#nm%)*X87S(_^kh z=@ti9%*k0&WMOn+FJ}8et)@vL3ID#9h)wK$(OI_?l`KPx+M9;5$ZM=^9>w8&#f0Nm}Tms+LPXIw%I5W)EJ(uuA99#htUU^H3pD z9^rH;slX_`Y8d$D)iDU%LZun4u?Pw6y=Jx5!j@I}sgKPRB8W+nqMwcL%HEtT?oH~rvW(5OZ**V&4Z zQNGG`2vFT2qNqg|du>=L6i9z+Av-F=fZw%$rO)?-XEGNWw~B#}l9iZ2`4R^O2sLn{ zdhZlUB2P$f9wcQcX~N8#P~0IVz#}M`j?Z1z@-UN#JC`PvEtA)###&4WiYh16rm~Wv z5s?Y1K%qz)V^2=>uT1hza4zqlC@=d*1B|7ZSh6H4LX({9b1WLdElgOc42CkKAr(coiWoRY%mmoxbeD2+Nn6_1FTEP5OCy zF77R(=92a)ayBgS^dMtBiS?9=ccv$Ps%8C-#di+dikC=|0M*+ct#Sxk(EU{Q^7~auvOk z_Xy}@SK3pjR*3|N`wieg^EKZNl@b>8u?Fw#!Kt7|L_m5m<<9wo-@s?=AQI)nOU1d} z(H2kv{+RWYT3ZluAepQM`bz9G2s#A%K|bU~+jV}rus17)x$z7MPLK+w;{f^)F^1@o zk`eeycxsV;?GVBz%efhYfZlb4cDO{E5Z>`bDSAOKp8beIL?@Jw7ocwMP;+vn?Z;~p zae_=>)nSIa`-Gs-9wyCd=`w1 z6Gt?6rYJ}%tiY2GYEK*dV9C(6po2k2L{~&#3JM=bnIa zu#AZf3C-f4{f#1NlT`!l*BF;oBea8t&tRQC+&)epHWky5%Q7y9cYa*90<( z=+;y3%bZ@mWXh?R)I|KscT%=I>M?=9io{N&C`2jZEA8n5Al#*!`)jTUtmFxEhQV69ObgVE_ND4-Qbdaw?8rCzLHvD z6tcvIu68Ex+88-+*Ty%&tk1qW&My4TDgu)VFdf<2@~dh}6pN&hYhoTMP|_Zz8RU$R z^Cr%C9|-Fy5&&37Kb>#c^q6?=O15P73f&t0Xdc85 zY7hEk?HTCFDZ3rar7FgX4eV4_Jo~9nS*uzAW%zh=A~lU%!}*gLk1vZ2uO2>2%2J}TVEkL4BF|L zkBC-3@y|UZZ$PU*=KYkCNXU11+;@oNfaO=$E)TyB`~HJcaAeX6a)NZQ@`DUqWg6K> zR1Xz4)dN_Pqov9xtPo$tS9a;J0d9Tj=!I!l5iBTxXs0USt@$ z!!pnrlmSpzXiKDuajs&nMrAnQR*NlVDq7)zG-EBg9i?5a`de2T>eTqu8pIq^4BK}5 zz;Iwm%A<1)t-g9*;SiuPq+omT(^r!+GTE&vn~+-<`)G3APL3Oe9VxTx*mDaZ$n2Pi(1<&2{CIo1JSJZkj{N7RL+d z4uDBv7sq_tP{GMqD<1CBWpo;WF;>~Fpm%UB>|2UvUF6t1GA~YOnNgr;-`ixsQP= zt8TP=Pe64@-Xn-LHr%GgAH0O$-(AK^z9H?QDv03(2-Won1L9XC>~W5DT2E#ObTSh*Nrs@zNXGo9G+uhJR63T&#;_!AOZcN?FDVqN)4merok(y@qZ+V zEj_M!;S^JMaJvVUw^3avyHV95l90}P2BHxk+P^y@kZp^c*q1BFv{2DqQ%3dyE@z`` z0;w$|Lz-5u{I-65y`Ju}`M-Gk7;xAn;5(U*K2x)D_XSJM5?q3+DvNY)0aT^>1v=?p zn5DU%+nXewKE4o(90xxDJSg{SsD!fJM?Zk0=7eSxn}WK;)=ASH;?Sv+(c75bh8I)Y zgw)#axXt|gSNLs@Ihyb~(~W=LH`x%OugT?-`;OX1cty{SpN{G#h`vp{LW(=gdY`|* zMf!0Hx5>YWH-Md~vm8?J7Jb8AMAy*|_`$r_#zPb$!tEA@VuU%&@rQmmV~CHLZz``Q zVcho=f*sTzf$*`3SkyoXlEk$A9Vf)i<|!rP&lFp-_t+bf5gmMz#tjPEPLn-&>{@MOC-e^uyNk~ z2o_O1sAKQjyqw;jp}S@DPf$yjngE|P&wZ5jUFscZFxR+&z$t2~!t(pe;zi!Y`*Tje zxAS*=psq+*Kp_6|mXyXp{JSdArT0m3)8gMn(`k3%yu8{@% za2HmvU-VwvY%+adpV}Fida{5BOlg*fu+>R@6#K;C69r~&Sr}^$NIh{YMhDJW)#c1W z08U5Sv_KlhvSA=j8*{_KUQwhtT-UUO1#%h{_p5zMdPvnwoP8E);w*!}29+jXvJzkN zf&z>D#*~~i=>av2I@D3g?@X@U2<&oJ)~+dqclaMA;u*ER0nf_nHZ4`%xI{<0gqrsr z`1no4z(!l`1M{I{#z?8$b5IrHm6Cl5KCjV?E^KkX_NEje!$p;BLs;w6PG$3{U{0Xl zJDpzg#9Su~o37MEyRaO~nT(Qpih69o}w`j~vW19l_CQLcM?6Za#->Yx4kZOvjA%lAA?dH{Nrlo9heL z+Is^%X^hH&q1$HTq~F8x?a7q}t&_`@QyKo;Oszb*5>C*fex>3@A2=6m*=QghyTbY6 z(cDX9Wa=;p#4Z=;sPo7XEr6pVMX00atS4wKJWTPcOFwuxKGDT`=*|UojnZCIk`}D} zvZg(4Jn6U#Qjj*)JCBZgS*^z)i>E&>lH`vA)7?%*C>)AcqmtY1Y8cB9$7ol<*|=aVu>Y9n-^LO1NO9wYhA)@Bw_0os}{Li5KaTS<)3ZdhAh_M55>J4cgNx z_LA^4@`l7RB$Bb@r!CbOrKC7v-|#-{VbvtILRuC{GMNZ%)1dVZ`g~uK%QMQavrvD9 zbX^0D=bl&ummbRuv-8dgKU{IquC|I{cw zos~E*E_k$L!qFF89;C+8dvq8Ww8oS3!st6+<~iL<3MGUu8$ogViCh~ zMJF`3g|3S~)46AQ8p$Yo>n)`9ajP)#kR(;A8|*@OBxLin*09`_h4}s&M@Xb!wN-Xv>2+O@wcpwbk$SP3Ql9`-BIw_85+H z`)tvD*5;}6k1E=*0z6SS_P{I_40eZE$Lq=dV&pg?yLQ+(s4&FOVF{^}K1!+OEx#}; zwapcqSXxle@7~xzU8$8;sVt6068|x_Uz$w80_nA7&tUq%f-;AWlXL*w4A7z?DFiVwW+9*{ks;+guK)g(sTj-v40_ zZTq1aGw*j+VwA)pMfTR5QzR`qZLJ5(-? z{*;4Ah@TqYTo;T3Tp3f)$AApsNhSJuqXV*;bj$~@1$LBPg$(}(U4t=i{Tu066(5;` ziNvcGruIMyFB&`{*Q3A+V$dX=fFo?3oal5bO4UTz5f`*co=Ym+ zVB^4$qAZ`xJ?Z?Fa0$_LcdC|Iy3zI#1c^nEwG zxNA*!fkdK1+MOgYe=<|yJW0k(AqMeVpj@R3Uf$9Ky5F$J7kiQ4m0BOXi1~J}0n}m% zjm0ygWggW4?q)GN@ebK1fLb~2>+mP^t3JaQ+?kMnw*Vh2)92~W*fnk*&kfdVgziwp zj4#ZnMpBtNQ!H1_%~I!#Bf9jN4`Z$%&9`30t_!QQ{9jKUaP@Tf453W(gjl@Vyg7mw z?kl!9b8o#XFmU64e!f1#dfr_!g5mPGDmc}}Tw4qSg3LLHav=9T<#uKmHNv)ffBZAE z`N2upGX66A`v0EUEdOA(tva?SiXQ+7()!mgS_#D>TZ?<8wUPxTY!OP(EhN2l2d9{) z#X8IZl1$Cb?CJ9Fik!ma191Oe{1XS+EVlxUdAVJ%l0dxeZJgwt_jN7j}_42Fgvi7L)4CyIRkT1Nyju!g2vjMy7{WOL=U3ql+TK0ekv!MGz`JOh z#!u{`o}Cja92Y1u-%f7JL$F_>46hbC5?`_mD+nL{VF;i%B?H*tnsHI$`qHEtott~KqJqi*Dai^gMd!Gr_`S|t9o zu+IR(C4V#+{zI@Im`NY2@CR=J`17KXb7VLc$ga21v!B{F3fK=?PVMD~PLT#wD*HK- zOR+!Id62~wjnA9W9t_5)2g4+j7I>~J*Dbv3zl`PK3}R(EW*hldyTy{*2@ximr| zR6BG#TD7!%l^US5(PUF)d+A^W#U=&kU>^T;tPCjk3gP!_pv zNl5)Aqk{@_nVFkPH7B9L#JUUN`sQ`ps-3jaN6l>uXO6v55IZep;|fZ_u*E)G!x=m? z&oeknm>i=Ec*;(O^<$sTg9`zsLT zG`4Kv&e(BaR;-#mu58SAwpVs7>ESc^nL33R)C2o?+NTv}6sE$(=NX6G?%_iAtsA&` zY>Z<3A*hixVVOE{7Fc!1#1|TGAG(Ky-uzSs>0SZAi7q^|%A6cK;qnsju>v8N%;K4) zJbA#@RYy3l7Qe@=L2dz^f?q6UVyzzG-fnnMLlCQvEcG(3PZ01zIzhJigKhUu==x9i ze`dH8c!LQ&C=gH}G!PKe{}sa}?OaTq>!UY1XkpEUVg#r`?Vo<>3ho>zwB zc6L9WalZCF-tK%o+2Q~G>43}6sG~4#)nTHBLj5_n^m_u3-9vrM$vtx(aYY4}3LB~o zC>W&&@5k^PSCtW8iE6UaDmk|RYhA>Cw9>j?TJT({aV$(yt{6^8Ser+qvaZSJ%CKBL z!_>!`ZV||2fkhf)p){dCW=rZwSVk?((xb6+SQ?Kq)v8vcx!%`6_yK4H=SCMn7gkqe zW-*W2Gb3uH*GwM}$rNB3tb~F8Mh9`crE3+dJ=`b6mXUyX2a{~!QfD{yh7pBnj zxo}vbaRe)8KomsrHLA=d%!%6w#S~bXqbZz@Vf6wdu<8JRl=PgrMYQW?${ z&GulnFu>wP;U#X>OvYOOh9{@+Uf#=nkS%HNx;)WWJkNfYDaIY}!Pz=N2Tv zj7e!{Zf|R9YoMW*;UB9#yq2Mtk(FnJA=47U>j%|=F$SRo$mvo#?RDYwlSV~ElJghz zypq~=b`VGD6pv-QPWm)iOi^T9X(iE$Yaz4?BzChqJXRWfs2fifj)fH51wj2z2 zcPRIq4Oy!cgR4&e^%0tz5X3?CD?oN1{Gb(zsI0neO#u*X&eO-s`x4Guh-75j`-+NWyBW4m{_n)i_ZRwO56zu@#xfQ2-Vt zH;Q)==Xw1xsk`v@pp~AE%UW&2hT#;^C_b~gh|lhirg(*7aTph$u3V@qw#ksHC< zp@w#r*@k}pwp6r~2r`s%&5=Iqk#7uGy$~Vmi&5Jf8ewyuNsBE)H6J6b@-EyJ)M#&> zSTl7AL66C43i{pT0d! &aruPXCGr!hre?Kw$RyO>X7ALXA||o z_zFGYQv~2L3F@Y%@^E&m8zsvmD*ty$b&q#RjXxWHLf_=TqidvW*l3+9qoEBEhFGzc zCDzK#2tqsCHG>Ycw`ll{YBH;NFfcM+M=Wv7lq>3}(#jEB`e?wek6mhPD5K}7yefEB zrh4VC-t#z`nhuqscCXZkYYc6y<3zgdg#!AyFT0-1zFQW}iFi_i3ss3M@)l}9Wq!2bzHn+6ym*`!oK1n^kS4tUzRuDbm*0U{qR9D{} zn_4bgp*;aCTRd2|L^}Q%5}^{_d)SI4+WP$Am@`TX;V0Y?=kIQVc`^-@VqJsh_Yp{& z&=y5}nj{>;&>Ul1dYLjvWFibiUGkp7RtCw0RzI8_ZyD3wH!`l9AokqBSQd0iF5%MI z^(F-Bxe0G04}BQ?FhV*2yluzSJsTh{jxmZUU6Vd9$OJPZ3RcAeKv($pQ}P2F6sY|& zImm}S=l^HL9T9VbeSU_I5hIX}rRt^$O z9!Q;pcITSTK*F@$ruY!w**&le*W&eDuT3d|vbomRRp_6G&A$TtbAE#v*=)>dZOl3ts_&hU2if{FQqeEd}PE%4u6q@Ca#8~TdykInt;4rZ-QsipkSD;D+hpH;58&as=t@9GeqHzs$v9Mn3jOVybt>H zL8iDEc}5!L{4zG2jwaKXwHhgO+`WTKuaQeoyg;KtwJ z#B*#S4kL+AeUIJETG1RYqDWJKx{mF%0wgbJBKX(V`%<+`3v>a_gX$8>c$W zNU6bP#hJ0crwbFp81{_81s@$m0Kklyj2NJ$CYxK#zH{YPE8uceQF@Y-#2D7VAja^Q z>yw=xo5(<=mTc2p%O&CN`qb5DrokiivHBC@)Kel|>L5)a%bexilDWW=liD05+cCO= zs)s~~Jp?1H6wgX)g*|VV=%aJEHBY0@D1&!dBjW7-kZ)BhR{6XWyz!26m0wP>tAo)v z#sFem;%z#c8elcVPDSRv#jv2vUVl!$?ZZnLberGdRKaT*0L8z=ZzxM4W9V_MIm!{t z8U{aJ6w!-O)ALJ`RfkD)B~0tH)(m2E82o3O469A7te^s}h19gkaj3Z%rCDY!v&PR+ zDG1I((ykbG5dLID{c`<*(KdQLO3n>8&8Oc)`t#MOevH5FS_P!fD?AE&_9nv%>Kf-U zw$~uuwcOcG-MJk8mQ|u*y>|!!+(bt{KNVJt=w%8?(-pl0j5O>1Wv|P+*3279u3|4n zu^x%hQl5)OlC0{Cm5(0GZ)7Gby;R%Q1r}v6$`H%;!%NYHBGVB{v-Y!U2}<3Q<1#XB zv{Eb_%oWt>ItaXWo29%Ogc*#O@*7Citg9?%2>p2aiWI4f!4A7sIyLCj@gvy77=q}1 z`wiE;X4NLGR3&`ZGsjq^+9KtB5>Z-5Kfy+#4-1kvt1f$F+S|xXi&*`25%DhKy6_o~ zyv9Aq4nJ4>OKGf7s?alUH67A<=c1@~=KPtHnP8z_Ze%LpE1HNEiAVJUV}d9)ME)Ui zf)5D?5PdIf`<>|T80_A*0!QFkKJvGgTfWQYFmr5V)C8ghIeBp2?2RX~Ekv!cd_dX5$6ls?jU zfQscFa*geN@z_ed3WEffOzZSX@7YK;+-|D!fotwS2>3?p}#9Zx+T`cYG zNUZFQ{&5C>sbTG+vV{8el&;PsLvV+OfH_SuPGTq#21$k(PI!7KD&rCMdw=>63C!+l zsGXTGPgKiVf4QLA*Fsy{mRij^GFIR>RlPiQglOIi2-;FJRmwWP?&o52(fek5B6zw9 z$XT!1H1El#_tV<@#`{I%GoR<{fw?}ALcl?A$ex9I9dU3NGwbx>ZQ@X!tEq@_tQR!~94{F+VWb%H^1m zC5~e^T3rERO!;h(oSQ`#Z=A} zYgHfMWJwje!I>G9<-8#|d1ymO%z4wYv)bc!XW>n^z2F-h;<4eTNLNW&298clxP{n= zAuHqYSjEzI&GEJ}s2~BDPBb`GVhN3|9uTQVv24cur}7}$g)GO1j3rW2ElC?*l4j83 zDMG=o=>@Y&&Ajt#MI*^@q%{o8TC>`Q>4sloyiESeS^QRPzx0m8aFV7K*L(yJIymd~ zd+-vb8P~E7K@stOnGl;8AIRG{84+`OW+G!TchA{(j2%n(2ot~Wb%xjhLA%j-umGDYHX;-_nJZ;H;~;YcOHjp8T9{IAq~ZT$ zQwbc&>{P>dfJE@jxiP(WWGRQn8hr{n~jtD~3{I+)4K8Zqbxz(Kl1dxT@{&gKf^%-#;YNCTGoTtq## zYOt?nR#WgH;}LTutRP61(c7SNV>)&W)s9j55Y8r-6iq>Z24SUx6f)C5sJTG=kydv; zxIdNMEHU-UL!;kXS7u3N*Z`gizm^W;yn`@kCfD&70)`x?%WV{yk>OI6_PN&qmAdI& z)?7tPNe0^U*u>J7L5(~3x=;B$lY4&ckUQ8Krw&M(Z24@IPC|cMel_7097~7vom2#s z6cv^9CMoHWXR#G#rYn z!G}HfrXu_kDkLnUIYAe)YFkYu&D8pg@&>vzDPNL(0*K)NNn+_ zE3C$1_cbBEreDNOLw~lFsu4iSSt%Pp{!y((C*RYXJqjhd;=84>>MwPSk z2G3LFN+XQ*2CI{u2xliXtp!x`Y@Dw~({IH}cfr*b;_D|)>n>(LozxdDT)J*Vtan}c z`9RC>_e?-Wonn3n)D5&J=lqUbX-wt6z^)$4YE&HJ6p9bYRfzw=!jf&SO2aDSN_F|$ zhT0tk6~6y@ozySkrj(P!VFhCM3&lYksf0|EB;F2N=+DEtY*AyETdgk9(Q%F6N82Xr zwCneoGTA^?ff4j>kyI(`6W8-ZyYB2>K5L?X2RQfhUA<>04@(fQ1DNNuA31J5@@8({ zv&rmeN66j$FVpdB=$H%q<)WL*Thi@v$sS2dN< zE&n;Ej6or}grN^x=eE2xN>TC6tmCkFK(JDk+&v*~uIQj8->ju(+Y6c@V}0}w)f)By zG}wBJF_ zK-fVB_|W*e#;=Y?mg?sDYSQ%tA-f|~d3wU1i0KaRvDln@RdI$6K0a+7A+93^Ipk{h z>|KD!&s=>(z;Effg8df_8|HsFHDGrR8{UNxv#53nGPgZP3Ma|iEBxq|REK))5|wVN zMHMd(eVQNK$KWN3Su}n?;}yMD!0(oxT~J(?9z7N1l6!yM_`!z}ntmL{Avijue+Kf5 zf7{`I=lH5IcH;ZvHm5EeUL@`*9L-UwDFS|2%1eJ!f;>bHA1dgJj_tmcc0XyA?yFz? zG-|k^X%2EZl5Q2a8XkNVFIG(^5Loi9#ruyoas&PRkgM+mH-b9E4(SbDpf z0~-zN%7niFUz}71&-(C=$#x5ZgJJu^z%!Cv)6J#W?}0ml6!N15Uk%&*4)AjtmDasu zl57iuilR*|7Sf{#(Kf#D2xQtFuBy$QiEuSCiydbKYo&H&F#U8WQ#8u;AU?R>PB7$N zdP)AY7vHGbKA8_b=6q=CeEwmlmkGmh!)un6BQU5)w}Z7G5HUrax97~S$8dMk!}lKj zhnbPxgD+1f%VYkzq|l%`rnl3OC~&*OL}DG-y}XH*GX#q`W*H!vgEGbax*m}`n;)`i zD9pDln)((VTSyjz+-CZL?&+7`=P%!qxUq#c2Rn_)K|VU@7&aFR&2^!548aWubnv&1 zRSf(zPgfB7yB0{QN zR6$Wq)B-cXTPUZPz|n^)bN07W`8yR{=}GWObN$C>jVBmIL4>M? z2JWr1tFxT14)3SMm1le)oqn9bbOrX0h?@IV|D1_BJ?1ZC>1%BIg)XK_YDxI6Wnb%h|-p>Y9w6)$Z`NXK*Yb=YfOVV%aYQpq|%SU!HU>%uTyb~ zG4Db|k}v{M`Ra@?9^(-#+tBG%Nv<0HDS8;SVWv4^;pCYxoxEqX-*XEoV8Sb0BoatW z4~SNzp<3l3TIH^23+%Q-Fv4z1ROvE|GO(%sk#|FA;kBWTMkWFIPv~%Dv)_K#BS`Wx z?FO2m@Jq%1nR%c%qXy(m9Q_O|UCwZzFa{K{ZXPg~Y(Rr!__>i(yYZJ+BvtHy{_K54 z%wnrQ9cl8@6$+?;#lg&Xdy~qPhY7BlH`aKXVI8Ui=VF)rF05}uEKwgyBonr#>~y&* zQcY81!?JoP3PS7tJ4~*zpfD(mK|1R8xu*%&lln{MiM<7_McZg~ICYBw4-BZrAKZZU zUaRhDAXEp~mHRfR3A&T&;?cSLt;LQX(=3}fbD(_|QB3q(I7;f#*PFE3A-utuf3hrf z5ufPh31iVbXlz+?)Z#Wyh`54Sg{qMG6-wuPuOyr}qV7dcSei(q$FoI2_#3B<~0E|dO0!rdvj5+)Aw zjx({XiEZ1qZCmFg6HRQ}wlT41V%t8kZSA{NyIb|`#eNr0S9eu+Uv%~Jt9q*c?Hlg% z=ZQ}YxhueFb@=DHWBJ(FNKMX#=)GIY2@%K%wPhEGHPicd4Ak+Tee&(Ix|)&eDqZlc zbAp+Uqj;Tg$(tYZ+0+0F-^%vLynW3`+z;tIXc(ehY137 zOzcznfUVX26C(gK8Ad@k@dLX3tm%(dzql=D7cf{P;E-vs5Fy-bJni^tzT{h3fpCAH z$A)52JC?~C!T;Bm55HJR(xAb>Y7zhcie#Og#7!L>{!hIBU(T%6wAIGY#{KPt9uoov zjx)S2@#6 zE|kO%N$)K8qh9Xoo@cM;=g<9mPWhLo9lr_enrSf{#5HAjQ)wTP|V zOW#Phid?r+aWe1$bP&~4RstBJDJ{FFxBE-%@Y7_Gqumwrl`41j)54|!P5I|w>MXrT@^ic4j&m4GQN%tAFnAII}P&^sb_ZU22Z2@SCW8}bQ~1Q9`ZxtxW&*P zIlsY?f^0+^@Q9n?C5kdX84*A%>yS2LGX2)f_SoWXsEp8hWZk`0)k&nN+sY*()mAl` zmMz4(f$Vczm2+L$lnI;6jU$WQ>RD=zr4_62BoUN>t(|9MSmN?&j%+~Y-BX+!+|Fm` zuhIEYi1xzQ?Xe#y(2ln@ciy4Kg<7GqE3{*s>PbGlp(x7~1=0oXZA7BoaT$oJ`TdD7 zLx<901)goC%}rH!X<7|xnYEXk8}TXqw@G&KN;3*GQ&vs;ZEo%z=lg4ox2%E1b1->` zAO;wSlaXu)#m*#cpj{3e$g!LJk&k-T2uHYBFV0Dx^m;z|$!*CXpNk4p(X*PihlD3 zQ*S5Tjn~-v2_NEdb~3_%#H^utM#i%7GwXe$$GL@>h>(kjU>%3I$4h8LYQ~6G(FvKw z2Pb2}KI&?=Xy_Yt98k=<#L|7=PJ8G#h}^D-CG6B_Yu(hbws&#DlfC8c4O94YCq`N2 z2dI5g5T&Br`mx3U25OqyZ8@4lI&x6rd^M%D7S5Z_iT$o=c;drzeyKJ zVh;G&oi3c|2(!078ARjF*^Tqa8{1MPBp=3c8~z!9oGCAmn9-*m7OxTc8S(;?q^^9U zmG*xBY=~-5hlW$0z2mZjfq2{P2$>FQM2Z;%DZ9Lqf>?LNbH4Fbx^-lH<3&J$-K)>L zv}M?QwI;OvmvwM|igf z4EAVEEYTn>;OJY`JFR$hiK3zMo^C?tyef$W20%|hG?wJTMRx_;0SDVqr(@%)=^1Y3 z${?JiGMp6iJX-kQ2*J%q1c;pBauCUJFHEH<>E9Nk^S~zyhk`*Zaa1&Lb6JN3@~=sZ zz`t>&*=5FAt)S%;M@pN?sWP7roS!rM+b6>-27Wpt<-kBA-~IXImoz|bmy^{BTy#u2 zHt)hMo002jHP7KZixGKCMq0Tc{$B!Ya^SESN)g^ekwWDgS@)*3N+JrPS_WPXsP(-B z-X2W|p%pqD%;5X~DtH1lxxcCYH#S2615@~aGTz8wYuzb><0_Q z%%Bp}1W<#Le2`@;dXUdH5f4O!hucaOrMM6uSG~>++(2D1(6?1v+q7uJN$sE^Q^Ea* zkml$$!%W+r+1=^%JD=WTQo$(+9p?#|v91hHs*6R+71j?;CW2LK3e;!Hcs`R*iU7%h{CUyK%E z?~Yh(qr|Y>l#{F89(HjTQv=69T5o=HLf{5P~IcD1r$+oRszX6eq8ygZ0@JG4sIU5nQAHS9^9bQbI?0BwJ8L%AJ2ME zuW~pawu;1ye`$L%x;lMz_gtPzd5PC^&ddclW?!jE9&eT$7ce^$V1?^Hfj>?bs+*}y zi}jenUHM#sl-RE{x1MUMI_zDeRd5_PZUNthW!&{%+70pbn`GkGh6(U=h}hAQuqw?| zxy9{@_K8Z&Ck)!&n&bvnzO{B>c@W zQL=ssqbf%{{c>Z!p~!f_`*glJA=<{!I71>97lFN@tVL_+W1$c??=2HGW)ingyT~DB zbT6Uax6do>BSqXbS4a>>zf~gJ+aMBdql%-Y7=srrJ<%fDXglE$^MwpaMw0$Pt#IPH zgYx$|Y5C{W8}rTh^DnH8=5PNh0{#Q1z#uN^J-?=K5pZRMmy!VM4CbHVp%6kt_kt{O zGdHiqm~oPEIE3V&d!DayyUsm557;Rg3Z%l|jJ`n@!n+XTVD-U`9MNd8~w z`d<%CesI1TOOM~Y&q{2`V-yNhsJJ3%0@#Rb;_7W&i3$ao~;t(PU9v?$yD`siP>yD z2V^Ab%V{TJJTzsZC}+hE_(`Hc9|{AH8nZ<$09Sbn`*ttW(baB>#jCP_bhjzf3+zr` zTy+nP1v4f2k`BtIF%$eIo=P++TqW|wAEJwncCk)BT9{6vI4Ub~h43wxW39$xJ+rsw z(AIm~hrmMPpXYHo-8{&gs>~RYyBOCum+*%(zJ|t@Z~+2XH2oMEQSaoTip!yeGVb>6 z1Bw`;Tk8KdGU?t*P3=Y?9{x5YSF!|CsiG|e3F+#ZG>2#FiPb_wGotmQaN|56wQMR< zDonf`myM_#BakdwTmlWAC6Xx!hlDPiwMnyj|s zGj)MDE7POW_OZulHy|k%^Y1TLCRQLPS(*bdD;DD^?7t6`G}5jU3qjME(^^xfvoZ2f zg}mW`y-XIZ8SzCPnPilum=2@B_X}jz>of$|#)!L{`;=4yWN}c{l650WYe?X;1_;Cr zYjYLUscQ|Jc{B%j_ld(pP0vp;nHh_QbmH_84SztvTr7#mV>L~r$g!q{BNY@>`!;XZ z*jkJSD6!Yfr&`f+SV+l$S4pSFE3vP<;FtZ|{Z6Kl0 z+dA7iJgu^@Je+p_PiWn&b#?q8He|+d8|&1;7Qr4X{>l;awulY>&Le|Uh;QR!*3_e6 z5V6`qt>(wTE4N}fgCTGZ9UmT|5me<5l_Es)~f!h$Q7@I!hPY-)d zOJlZByvKN1ZjB~SMJjjY?=29f%K{b*&H^QTuzX-(<@Mhx<_uhe>6GOX#M1aWjbZs& zoXj|88`{;?He@IwoIThi%!k^A3#A3oi0LWDerF9~{DjI29jvPC-a*V0L38e{A3=`r zo^>*bWwgmuB+&wuK;p|)jZVtVjzaQ9bXGa>M44vx5+qAo@#a5;)h$@Hap#;GqGIA@ zrmVTmHTL56(T%*D0$n}Op8||w9;#U_Hnq@_D{nT`TXm~@l%MBHqCwL2&>l*Hez?e+;3I=7EZs)isjpA8jCC*=~%Bd#cYY1FC zWaaIvlVbKvkMT(^Ifoux!C2nFSqf3WUL6gktiQrsz9up5C(3?Rz&^1kjY!tk?kN^7 zuMuT;uK!D%hKj!eTV258JiRCmjw)k%qx!{L$xWcJ|Cy4WS0JuXs>5FxloZEuP)%-t ze$VpOUtb`Qs7xO_OO@bSQ=CJ^p;`pji?}#0Eq$UN^)X{67?PE(z9N2F0t_}rStqCy zNF!#4N3cFKGU^K4D@-pis(EZU%xRs+rWuTrsb~SHN~kW_*apFTaQ#3sF1D?18BGi2 z6LZ{CD)pjLI&;Lx)x9I~UXrh$^)z=C+e{q#Q7_tLTGqzAHOk>N-?`0#qv%2NFo$vi zWcJJ74zT6JUhg=SW~ng|&G``WPBv2UxMozqmLidxc3YFj*IM!5a1PD}CIvV1#&rI@ zur{?7%|zY^#v%sa69~~6cWk+v^=X*??1Y5AS0J%a)%T&dd30yEP&$dRSy*oObZaVx z%-L1rNm%@w9XR|>`8h7%S70367Mzon{Tso~Y>DAKpnPd=t%xj6L_wTtnDAS?Z=&S+ z^jW3gwzET(;hoAo_Zy9?=34a}$sTGoOE@qjSCvsX;wVRASl8@J>^obQWY(BfkUH{I z5HsZP+C`S7C#2#++?|XBDZr^FJX;9QrCUco=2NQr!~uH;9ge$ z2`dLCLfIUG7-Q|;(Z{q+Z=`%L-sq9?L8Q9C#lOY`RQtjP%7n>c!+HQH*xa#kIfy;48N;<$AQhxGOc zf7G?E%B>v!12HQ(funW^EKYzE!$_C(AZ*rO8jtZ!Qc+zA5+Bg zjt`1+QWH2kQ4?UW4FuiZy53}aagfCG0wwjnG1je92ttu-3|tmz%| zj=RR5M+af^+J3^(k>L--Eq_Xd_3-Tqiv6i5B zt?yG{pSeOP5f>%L?>aD>plyf~xdiXdJ;@tzr&C?_WSVz$2(((3$ZI=XMdVd`?zE$$ z5-}R&6gnoqMIwD+k)I5DtPaI=%(XtLNW3p=e%e(_$TBwgh|b%$b3uZ5V=Fy6&fe~u5!2>*j1!?C6emVZ>pbmMmp9?kS?nq{Q+)_A)X|>sn z-F4hRt1tNg>lz)QEx=ooVu<;p0y!10Zc2+C+VnBBT^9AgsnOzWyP3zp=4-|+DC8&Z zgVEq49)L98jRB!5sPY82;vdKNs+F*QEj;yl-s5#aEye>+AgnTUO<$9FZpiwIt&>sF zv%7lt)%MZsQ|F!wmt^(;u~8*A|N6SV#`Re6S&zqEC$EsvR5`7x4y1a>tvW`h_#3Xm%}w;wQA;IC+`ns>e_5}uhj6BVmtET6=D|d%2v^Q|0Q?O4#?%kVX$5lvxXvFB6I-%`p)uHM8U6;C>n^%^7qP?_O z#=WCb7hO&WDub5j)=}AJKVMRRVsJ|15EC+VWbX5+vO{>8%jV(yF*_~x7BV|Z2VQen$0O~*3<`_OA z2qfnM{N(4(o9GEDY!yEwoRMvv9??cJ6y z^QIT)=!MMvg}b=j0C1=zP;1H>RhwjIrYmLD*YJJr8ySs=11B!D8+9c-BkOD5%+_re zjahgyp6wDZ;(%RF2!S&y2mag90vM5tF@}#(0YUS*rDKfx#Nx1N)JYZme6xQ?rdF=U z{$Bb}5V5)s-TfJX$o~*&;ud2Yy@TPa68o%7k8qDTu{!m`K zPG^zD-*10>*z%s>XevM5d(#vJkdgm%M=At?GJ!uTP^q!oTN;O=IoZKc#Ff;^_s@?f zzTsfz_TK1snsES@+Cj4+3Q%hw^{?4M`pY3=O@s4DtqVzr`@QF6F~K|LO^9qTKiKj0 z-80*(=5Hi`Yee69F~Q~sWZ9e&KS@Q5q313)+Y9ik&@XRir{E1U)*sQB;0Jpx{^V%~ zu{8@jUB(W+Ryke9n*K+!Le*ZIx<|`fE8g$lINdVnkIO0>VMi*WncG+-DKL@u&Teuv zuf*R9rxW=ovd6~#NecK9)WY9%rzfN9z4|qcdgxb(jxmnHUs5D|8_)_nUo+0U$i4G~ zP2atr&g#80-}{F+MEBS@gmVVEZ%&eew_g*o- zVWiBbj$P&AwxYHaVXnF7=aH678wnKl%C* z6nRBSWBPQ6UsfCsD%TB6F!E=V#~TyLXwr>i_Pc>?*5FwY;N({<22oa_Hgnsznt_&5 zGHK@}kiu;3kiz*KY#jn~AatBb~iO4}L)=iJHnkaMS$B4B}ktp^Gr&hP#JEXEz>R&yJidlhg~(oDxy zRE{&wS^R%4m7Xl7G{BSogl3JGcd%3Q(R)J=)1gbF_2R(-&+jxr<>GVn0{>SjsTZP^ zLDsBhXfBV=LbDE)DOR{$gHv7^O?w76EI;N2syrjBCUoo7s+qvFA3SBHk$rS>r7COS zwcx0jPy#V@GOE|$kOB#75wc!vHD@ns?o!y30T7H_bO)T=puJ1I5%Ql8xp@IMB0h?K zIq;EFMMLylk&JI{m=Q-9wL0Y-O3jvPc{Y%IM5ZYOrsr3w%FWq1y!=L%jKujYC%-k9 z@amiDQeFpnt(H1;cFGdWTBcDr9pkQ<=`1anC<`G*(fkzgm8@4E+8)eqPl48=!Oa;H z)85OBVN6$3ej<1)J67e9lP374Yq1>4O-DRyI&uLtmbqtdIY2>0;e$-D zv~rmiWdl>SOSu-b^6W`Pbcg)*aR(R9h3-HHH-WbWCnMvHbiWbvF>GkG%yuSmgPC}r zTkF1YKlRU7T>liyX*g#DZXhr$J@k=_zf&jLr&f8B!AVueUvdXAd#i-%2!p@D;|18r zo-Cx2y>*3c{#)PY*dzaij_zlIF=Qc;gcv=$CE!|PMWmQ9ajmxYt+#G}?a&Vi4nx3c6+_C?%r`Bvf<3`2epju79O z4@GuF0(x0sSs!09pZnp`k&mDk-p>rMV12?=S(6^ViC&6g-3kLSTln(9^*6U zt|EI$G}nqfz;-_jAux5Na;+2N-i>Nu{DIg)ll>a>OSKVH`toU{xre>8tbMS)UX1pG zD`)HaX2Iw->KEr}N>5?6qd0DCAMBi6tQ6NNkx%}MkYqQA^EOxl^(KCYMf4+~vdMYP z9Yi0ZJ+>F083RsM2VYLW^f?&t_!zwIj_&L=lntVWB=Xc~AL5_J!8!u8G;@XXFA^ue z&^kET|1jhlAqvm?GTvfwzDBVcDf4H^EsP61^l!lVNDY0C%#Y0X8S(+)xD@B=SYIZj zpa9la>o*p^rz2ldUs}-jb`WK8`YiQcb%*gLjRIcs!K7Y^)OCgP!Z46hLIaKo9b=6P zZ-F8E+$^ZfPO4noE218-fJOSUuA%%?W!70e1JcT@MdxWc{X%^ByWs$_V4-a+L=k3M*8)gl{<` zUkyX`H9aljXD>~$HF=W@fxA3Gb83|}ndUd{`I$5`^xF8==_{!m4}g_%IG5{SPJ(yBFcYqW2;f7qGe6^Qc-8EnEW-2qaP~=4I>F{-II$RUYny6L|b47-vN23mvJam7!B@TKd)j_>91cIHvDe~P- zqCGG+j_|2m657tl(no-h-9E-S=X@^eW}~Cj@~UOco)tL-oi}~DSnxX<(ySwv)W6AI zse0_yOW9_S@^CeIkMQdM0{0_?kn;3NMBPtcL(lf__9aZS7FH3ClqY6<;=dONynO1uORuJFBiKBDLn9dE%ojxpa?$;|H(JQ6*ej(ZD zCEV~$zHyyfou!yV!bJ-fS1!GsjlJ}K=DoLFETCco=K+YU!2CM@%rtb9M;l zB@a{fhV^i=RVeCw#_Y56jj6qYqWP;WlFR}73z0t8MEmv6{#xS;^khxUqr4K}dT@lJc}gNHnI!_6QH!F6V@pc**X&R| z{tz8-FCnCRLhe&G_tMl|im;dFdsL|o)zW}EAv1X&EfCO$OFMp$*tK9toGx73yC_UF z!%jE+s!WLY!@`GW7YR)Ak)FTO;ix@?`T^}r_Yvds&z!lEWT!vVyhF)-!W*3c@m_wt z9mfUqSMvGDX&U=*hd?;<9dk4P%j{na7wEx%p_b7w3l+UWGyYM-Y(ht7smshDhzcP$PWI0&cHjf1*J z+)C`B(rcrFyMLuYyQzt7nb{-D*DeN`w-~A#9~nEg2yXkYk0$O$q8P}coHRAHcsNb{ zBTvupTV5)ESJqAu@eWJPt5lEo?~%hfBK1t0hC2}Ra0*VHc;Y@aKr~}N`{%JWoYJ}Q zna#2F=QCS+m^E9Mb!&&M3FajZ9%{ezWdGg)>6d^zBvZV#nmx&%wWgaJNj<>&N8;Pq z*U6~E&=*_VYg>&Vupv~8+}-0-#y?z957MT?+zeuIcqZ)p)_LXv7 zq58*5yul#(9|%0~Hlq+}Cac}Rw0?=Vc!Pf#pNaBo|ELDevYiIRzbDLE@&@ObDIVua zCqAT^jwOD;p&pmd9*$xOu#qQVgGp&akZayab{$kv5Y}5wo{e%g-l~yiqi%hXuQYWa z2FT80T4lpWvfur;^+;CNnQczrVr6NNr{IR)@GCv+9NkHNm~ZWV#r~(X({8VcZzKlo z`(a7uu$=RSp`H_cHAOEaO;|>ej+J^DdKN8Jb?`OkU;hKDt74LyzDz~S&@|u|c^i*h zau3&s4o2^47H;C;X}yhYx90tvD!%l4{KW|*ie{g{28U8PQO97Z7QO>s6o50TX=ec(0C4Ga*}Z^X~o#6-I?3j5u&ixi3g4 zNuCrPM1!Acz)|Hncd}Si__fbq5vMF=)$EVBezZc|d(upfQ$6pHA~qKH7*B9|7xRq6 zAiOO+2O5S-oz8M!Pn^J7C+3hyj{K6HaQ{e7?O$XPapfC_|CF3S_!z==*WiAxf!1ns z_@f``4}Sry>_bGFc(>OnB+xnw)BNPjsUpxSHjV3(sud&1wf*WOH&zyQ%XR~0(W8oI z{5AuHqwf88jqpV-s>PN^8f#{zWAJW@*_E#6pB~3B4l9tKdTcn34$lrDCUh0M@zT#5 zazV4rW}V*bJ3>K?y*R~6X)1}fNcMS4)|9!66*!tpTFLO1?%qW1tX>-!LTqmTc6c5PBiLaEsZtxADiIbmtSGkT|SZ-UO8oBGQ=&xpwOfcSH zAEqCdbDXntN$f{`2nj!xb`npF{8c)I>K^}yEv()9wQ?}VD3a@>m0I6MSQgR-Ek%79Ay7pCV6`rxGfu^>)QS`s@&a5bS z=gXHWQ01tm_eAw@an(FSm1rGXS~cAtcg{xS7EY~jTg}FCTlHE4N6Q*rN6pl=8V@_K z8aKPpwkE5OMG$<9p1n2wL_#iKZ)cqqU1y4Rd_8TQoZH&Pw?CF5Ej5g-nm9<&e z#u(m|MECzxd@HiJdJWL}+Q;=(Rcr6CQis&dy;ORO?5d_Uu~&Z4mn~QMYG#hFoZVI7 zAJE%ZY4oS-c#6||(nQdgB}o*RS)8R}ND{uM}E zxuKg#sOOmY8#`X*mYc1I=%J|@vr+?wcUzFXqLGQxsma)WT3ba!P_J7^S4Fqj{1*Yx zc88@|rJ5>LRHXM@H^-i%Rp^r)dr^l~#le+qJBl;?%of|^w8IpYvtZWTPdBafp<^s% zK2au9q24ESlvT~$`RC#JA}wTHn}(pOw>+BD+GwOn4kEf(v2b~H15vBc)2O|Nd=qp2 znG8Vb?kQ zwJKipkYW@%{AX^Mnu_BX+Qc>5Ugjyi4CI;mAg!gCACFqOwf-qrF3h5IsO;#3 z)7D~s!Sxq@aQVjoM_slOO$zl@At)@E!%`8hGo7SNc2Q9s0f7WIWyJQ(v}#S7`l`HR z3*c4=xc#j+uUNj`f#u-@MBi-r#Yd!U4N$Lep>aGZQ1Nmkc3Al7FZa;`ZsS1%7;t(1W&$M z2_g%AjZm=(x5fLUqdmimQ#B8Eo0(#gX+(6fH$;nBr`ntrW@V58zPmfWw;Wqvbx!^L zG(7xtBKTj{6s(OJoV_0tI%W7oKf7H&?p;LR;b9=Q21o;3PHvU>LO~3@mPE2dt zG9F(!#@4uZ>!sT@YRpaXgiGjlf;Q=fyIfN=>r_+Jj#)#!Ql4^r#3m$ntWq{VXRufH zY*_W9>K*bi7(f(?r>tBTYIR&yY?50aZ{#9qkVT~b(?p17+7$GjLj7CBdKUjq_s8}Jj)(#KF0aKMln}TWYchlY8ZS!d#d?FGPnn^< z2+keFVkfH^sj`-%NH1(Vs^zH;A&NVig{q(NM*^c-_=zDC*9v{l2&LXG@-5~=s$O@P zjC}WGpUS&HYIQATjaH2Q*l7dDb1464SNQqUt*kqIbkxYz{#u!s$|gjNgW{Y+58{qg z`JV*;Y`0&?Go0FR%8R`}=I|=nHhxwcYM>{tqF4g;k_NHIh>ap9T_w|W=7^09(v8gI z=9uu+j$7|Up9{A#c2)F5CRk?K+6^ZhBJt(!)bY!p_5?;p%74~pGcC%j1OtTL=#j9T z#WA-FSwVt2Z+&xc51v^v-vwlEs987UI-l{R$o}PYZz)!D!nVZgjHtrplrrh0g1K>a zTr*JY!*jSs5UnMqo1s~vObol~9Ko28(+zllu&Dy}{(`WK_mO)JpC$=(A56jYuj&WD zr}1p^obm_6!w2QbD*^sUl9CCI;sFO82tX%!&i16jJ6^Ra|Azo;iavhBS ztfHv!{;-130NcR2UkfT2@D{30cZ@RS3DhQ|K8TM5h#XuZj3ik8!~cuCFn@3thU?K^ zMT14(g1VIhTZdTwUmkLBiVv_u5p-%M2_NUs9y#JM01tPRDY< z7@E8|9(G+h=R|lD|G6-e-u>trK+T+XqG#uhS+lLMIR_JGSYWR9!mAwZnUCPkxojw zv*!j3Y-ek)44m}YCQBC`vU1>%d_Dl7z<2lUmr75@^1wp zIJjbSWcE39=t*)pMk%n)cM!Z%$>%K0txpgwgX~viwj4--oOVl#@~L+J6oQ#B8ufk1 zTh&QDvN6n(=p8SAR_c(KDHvDA>QqiOt7Y4kQY`?KNYC9wp0ICDJ-(`j%F9)%9d^4- zZC!uDK4#Jwmw*v~hRv|lXw5viU@moCN8iXemR=ML;^ zMfB~$m7ufOU6spx9{ScxR5*-mtqOe3E&-GK8+@A0{eT(n0Oi@kfLRC?n?k9I@e51x z6zc_wnKIct#bkkKDyk$DJ~H0P)NUK|cjhy5>oOS&!;&e~jy%TSS*a6{o<%7#C2 zf8}(!dy!C(bK~lm3vqb5KQoOBjry=u5(}Ad`7|+E3k$2KS9b|iz_%Up^R9&}o3c&m=6Dk^Gyf~E)1xdN(%Yo|x-bYNj0_A>P{$X-u4qsdxE~yth?hEn8 zOeJW;V$Iq77S+j7Coe67rhrgZ9t2+oL{Mg7ncQMz3l8J}6Z%;I&Ur$I(L7q?0U0pv zh3u|ts3$y-So35lIk%c>MMeHl1dHDon@=;c|%$ByKon^ z${p(|qEb}bP!Dcd(0iF}y+f-Q;LUQ$dJGbW9g6HfEFoq+M?H+%E5dR`i98;%=kt^P z&?v3DtxGnHl_4;s*Bmdu$6$hdTr1KnpP3k=OFchyrvt_U9Oop;s5PqIcg!FAM$Y`>ul#eBu3fjv-);3zI}_vbeKEcHv`6l>yIwWrzGkL;7M1z| z`0LUL+#R!0&i*8&>m^nft1(+2Zw_rT{O~*ODk8yfQ?vE)VL!YLq|x}1;Voq0W!eXA zH>qb8gCRRN;b^S%!>FhpH2STPk#Dkoga3ity)YM(bUd|q6b>7vfUA5B9_SfIyZYuW zy^jkFK_+`9DKMTckaTg`)!g_xGT}OH!=N8M+q7~ySKM3DpRIbc*+Ky1e`rqAST7i4 zjXf?7mCt$@Dd60pcj|m()(u`qj|xvtoU%HVY;^}2Np|hr%DqLuApHK8_ZNRFe1XMr z6RgJ|xx)jI3q@<+V&Ag<&UYc0_ZW;1?=U)$#U?L7E&i2y(tlqq(-(cbQX=0?xiH8t z;~&n|cf8b;a(+l9r06jU_}FYhIOWQu?VEb6bDck?OVF*0>>lQfUFkd`_irKu=}~>w zNtGy8I3EG)8eT?QWRKd{E&DBh_GtK?bjE0RG99xUXy0bmoxd=wToSr%#eN8mUQ2$# z#UO+ma4bXuSiJ@vu<9aIo3$oInW8r;RVO8hSYWT_%)b}-?{Cm_rMR*oeh z-;QSWn>Q~Irk&c{{+yz_8`KrrPr8yitYqtmcCOcYVhQtA(aJs&^N7)HOD>jvIx|>i@ z_M;$qKzito<^vF;sXe7tCt;=>iwgFzYL($I=|64$r<(u7(K!?X8ZU&MBxXX%b$)!I zwz7SC$gkdwPt`q+h`NX zoFZV5s5dfzL4gXFCHN z;4+$7kwts|<(BnY7FqysVMV3EyHOx93bfJ9uG;$4pi|s-7*q!dLZsrUyusqGU|)Bo z!;wnlKaq;DA-bx#GNZrbd!wqnf|ZbvPL4kBmTdgot%0G}FGBvM1jGd%fTLM1eYfiz zK_SV^3GbH?ad7^tl)6hDGN#hiQz!0Vu)#c&)p_uvb2CuZvW6F ztN&Q7gHW_Ou}*rOB6x9fqawC#VXcDuK!y+H6k4W!_x^;BH>xhZ*fyL{yzTb*#7HQg zcn`sb#8@W$<^F-qSYqc;{$#|Lwr3=LuKvaCjoet&|Cs(1<1wCRl$GH?XIkV;JZnVT z{#5`4y%S%~v`Z*M4?NX1?}c#j5?uoM*u1f`GKX#m+S}g_UNahD==g62T(gWp2{(eY ziiUF?{4XBs^C``;4V&P0ofRW#ZM~M2SxqfQifuh4@JCGpd*0mm#FSE!>Jsxb^m1PF zl=g!-P5(deBNgxx+37>^Xj_B%YP}Pw4es7VDOZmwwcIMi{*iaDQafcR&s`wt8(##NFL;jES`|V5 zOQcthlhujG0#`~*l^tUulM4Gcg0pJeGke@KWJKT!n7AUggOh!!znPPLB&now^ttD3 zNYMbuyT_Q)NoI9<9%X6^1k+MWS!b-Svq;`+!S(Lut1rJ<(AM&jaL89l8$;Sss`NlL z?wP5tJ4-FEldFxDUa=Jla&jey0BE@%mw)nBl6dZSs z9P_i3VbbCd#HXyB7imj*1zk)7y*Ce{x!PLRFzgCb7YJ_*QwBwDa_rX7e^RWb)0V`8 z)?f!6D+Jz>3%E0YBAZM$k9EcMFg@W*OC@~%k!m;ITwk3JtKux5DznP+Q794on41rV z8u~G&oR*J9qp^0d3Y!hao8{EW)`~|`U@fv8=lLW72Jw_msO>f=J)*+U=fCBI)oE95 z1wUx!rC(W7vw~=hN=)Gbz3ozvVfVcc@6+|Zfb}%Q+b!)zJ}arxg&Z|nW319@{Ok_n z3U-`iVERJ(G9eqr-<^{l0G&=EL zo+vFhJ(!96Hw4O;GDy6MPExNFDopO~lD zvscd_ET4fSg?*#wA7!&U3BPIRe{o>XbLfsOSYpi_NTW(99M* z9t^ISI%wgL7J!P*FLXz#rl37bC5Dew6uRp|=plQdnY^Q``Iohi-Xa zF2pwZv7JdL2K_dp6Q-I$m4O079Po2kV`wK9+U%nUD?(7lo$=zjXo%_w0Lz)-z7HQl zp@3b(iXsPCDECA8vyVYtYk5Y~&Oxb9LPZM)2#os>!C-W+aQyXI`m7!q+GoYM zFfYB28KVwUQn_e_*hAXpXywx&C9=T32k*YeC)Xvz>E6~o|{uE{Y z5^`GWRnwBp)7!LoLB_$D8sM+QPy6;GLxD!@*sq6cS+~`u$3GOHH&Cc=KfY1_{y!&FHxb{LI7fZ)`bWR~mT*f6O&ikH4+| zYYB8e`CC*zf7NvTg`CXpKD3j}^o>0hMLDGGZbH38tJ!?Si(gpZ0JHEE8UHLV^mAcN zpkM19TUq9Ru=dU|q6S;KckkA2+qP}nwr$(CZQHhO+qR9}?!J3)&U|z7)sHLz`BoW z8#l@;OHwECEI?3xxY!r89_D)J$KB?Vpd@j=C4R9>#I#hMP;;b^RMgQ#L}NAPKEjM0 zE?@7zta8cvFJElfjUzf#xm%;zCsiK#W#N&{01q`~Zaw*Il|kUP3!z|>HkflQj1SoA zN@hUGE2?THL+a=os^QDl8Eiv%@5%sz)!7Tz zV2#gFVvD7}J=keVq*1}NB__FPV1R$QV7r;1T|cxZD|wO6qC~w)ja0==g0}QHS`hqwTrQT1>rusfP%VYO zW6t0|TsV&HJUJnI4*WU%fT+wPQPxBOj)d<9KXtFns7Q6iLqVqIYO^3W)%5_AXp zY_in3aR>C}L-%B^%i4jn}6+3d=)9bUDXEMrgIE1pI0jQ#>cCr3-YO43-{&%A7Q*Em~H9h=gzw&LcIW zZihtcNnp(e5<}H%Zg7Fsu<;r{Vx4rmgg9(SsyZ)2J;DtU^6VAT-^T#W`cla*3aEB_&{g zmw5FPw*}KQ%VFxg4|3c#=Yw-#))^&lpTV4k^$cmmRK%u zL**Zpe?X%omU|Dg19XK-lFEDu3EPFdY3Pclrw>w+5){gMf)FsuzqR=zXG<$6J0mc` zRJJdg#IBE@@wv7Dwi}T6-^;-qcPZ!0!22kQD6DTPoHOn$_(L>KG!=BH(+Xk-dHOQ6_|fTT+aH+NCUN`f-~CAN zb;(v0*cT{@8ee5wkjDz~8>BngaM*NCE`iLMwtc+ya=0(u83LGVXqN}LJ<@}C$qE~~ zUn-+NB`!9@Q;Bm<(p#P8->la4oogDU#5^M2#ICFv@V9#oGijiQf&0#rv{_=OFBj2+ z4M#v1;bch(6HNRmmSE&VpeY1<>8CkfKrZbF1#<$+XrcFJlQ*$+-Z^QXi+NAkZCtOs zhW9vfNqJ43ZGO2XIT@dagDi%&dme#|Cr;M+O)VjfHISwn%hF8dXr=W0$|`ZZP^nuN z+y-$mT!TD5t=2y+Y__On?ZTIMtaMVKZ0ag z;+Elc00xaX3xJYE!3MSdFiF%F5^wjoBg`WcVwTB2sGK7Sg;C7l5Y2z+3>r*(Zh{P|jKMUU_Xf(0*0hv& z3|h@}#w6%9lg9|HVLMk1YK-C3$lr?g6n4|Rn70YFZas$$x@|7k$p3ku`+HE=K2`Wo}W*xXge}o9i ziq^HYClaWN(Y3R;49beWWnee7_Xr(>;nmo``K)!rIz%4AWp*bGddo1RAjpJa(9Exj z@#!j9MK{DGD8&MdS+D?SG-iu&pc&#e6DN!DKrV>GFtixLW)Ox7<-P_-i-B;&(I1Bh zt!^^rAXvjZWFwekGX@=E#bB1oZw=~<;Z%>G%R+SG9FD&MZ6AZb#Cu{iW(xI2Ey!az zb_!(@c#8Sp9(coW1R2sc6i17303oPl92OYDH5!Kr-98S-idnF`}S^||IMlVqkaDa@Zaxz z)9SK^$^!xbtO5Z5kp188eE;)JivPI$9jqX2yT}LUZOIvP7{VmpyO1)gfM-s;^=BX- z3>+>wY5_t63E#s{x&b1Sjm0gyVie~U`V&9~iysi*o=-XElWfQ;!H3X(&7JWu-M#bi z^J8{a7vM%eT|Xb*x!YuEzy76%6f?W!A;;3IBIk;As8dKpA0tY?VEUyE^{(zX`(2M= z?ZucZ@i&y>@rC%b48mkxvraiZl}cgTakH>OY&%r)=pda$+l}gu3N4r$H{2zZNxQ-% z0mx`@FqB{iWzqq4zg!m^Yb3o9Bb{A%zr>D2vHAB8LV>*|!8%t4mwJs>*0fxiO94?_ zek(b`&JlQN*8Dc3xoHSX35%W{_Vt`Yly`YI@e#8)pm;J2 z?8+7L3d;^!EQBzr?Tk8ci@`JRdP?HN<|dkn}x!q zn{tRj=7bOCGF8aldPHkFsO}u6TcZ;ha~SAm9OZ{W!?$PI@T0_uCO=y04I_(Y$bh6 zmr6<(zF_(%6Q=x}FaMGtplqdVn#Oy`R_fvUAG~y&sVNvp5ysh_W`;V}dZ9B#mw*cr zSN-?22l{J6P-5K1v`6~b$xb~8RXbeaP zqxhSaF+l%=ZPY*0Qqt)kiK#7%$dAO`NNueimqI|1SLXLnOmk<@G!ldsH5@@!629Lr z)tbm~eM2=Rg^BaV{tXmOBH{HUihkRq3@0Y#a+B`)H%@ofb9;NdfbQU4&`$=4U@pWX zFZc4O2UQkvt_kSP?1LZPy4dT!p{C_YnECB%F!NA+?mWE0OF;5T|rPrs+}E zYhIHPD6^o9+=Yzk$LV+Emam5cl~>J0i1MqqFNlPYMB-DR-h|gJ%hpXZ$AW;PpUCdE ztje7Se5i)ngA?+E#G9tQ{nRy}8IN(rOKg-XeHO&aP0(xokso3cyzlbUf0_-`0iV3w zayaN}ODqt5%o1k@0$m~amgBGuc6n9~IX*0e|9KsIfvEaDlSzd%=0<5nt#}8fNV^sr zo8H9PFrro9)te2gAryewPv_E7|C@R;&L?GwJgruYxMjm$`pmTp1$kvkQ(@-9xqmIe`x50re1 z_!b`jIqXwm{wHcc6=|2&dm#DNCMAtDGv5wWP%ZDvPBwyHi&(4ZIFUj!t*}{$Qd04j z11F{P56lfb56K@4O(Tu>bSSnfc1A(}p)->_(uW*-sKA%UhI zB3R31(|7}wLHt^>t122oYks=9FW;@lCgw{+VtPqib~!r7;>=UePr-H3i}m)Ywl|*D z{rtGHY14;K_Dj|y&n&yC?$r>l%$x!p_ zS62;(M^_$)jMpT^#{DrxjlFkc94ph{wC!+YjK0Ysh4Xl@M3XKgJ`EfiqtqZaD%+|u zV)rGJ{%_#VL%(j9GiJHs#0PG2nWn;j*xQHm~(z7e6|?FifkQz zm<~tPddI~DprSS@6#as`8;z(PbUEGoTyL&;=u!|KsQC^ZYgHCakdV6Z-PZ_~%;-pV9E1myhL+vb9KcFD11(TE7|EZhsnIU`i_% z$KGYae2I~J5*>?LTP%R6inV1qiTZH} z;-HM+$Guo@>O)EjA;jpiyMGC=H^q_jDrCv4eX#D{6weK0Wy`Y?${gMeq!6X3bd{P5 zxP?H6OcP-mt$q{U-F*10Q`W8XkH_A?d4%^CPw01wcAJ7Z=(CGd}s^E; zpBHs>vg+dr+^mm^>YB?MqNYX}(4|^@-2LDBTqCf8V_;1xF^q+s7Y!E`Pg3f;clRXu zW2v$|ePYH{WSJpY^sLFpzx=beuPOD3i-M9P6 zbx2*0w%jCp`yY+-*7I}ebEE_wXv8=ZrODaA_R#=bOyEoYH zjAc}4Hm&zQ@y92dYRhI~h8Wtp8~iAYL~dVJE<3T~p@?Ue*vOu|ZLhFHrONeMiy4|; z%J;?Esy`lJs?Y;~_@p!5-J>k{0f@??o4&Bw99gZnHlBjWKzfBccDCIC?kdzpu88`I zJO=I_UW?AV`b5U0HhL5K?Ai^co`t{l4)loujH~T|*pQ)tzq{HoqP#*NUTt~AI1=Uq zG1kvbs=@V^U=F7BVM))_x6Qc2SU-Hxc-7AR(uEV`r$ZHjtFE03)N>CI{9bdKC~o&1 z&&So=Vvku|@_@nTNwv#)iGy?twf;S->VA7MyleJ&`w95~E46Pqb&%Wi!R$0sZTF>k zkp_AT{plmK&(e=B!rL?24$xtbXug$C9Q@#*oIeA;b(}@Tp>9NX*q>3n<2kiCYZPA4 zVrm716}t8vHcK8HC{k-S?uyB?*KlCJm&ERN(iz?d!-Iqa?*_BW&mi(i|C0+B@s?YN z{#(4C)gU;9h<=ess1O$;wn8~PU1$&4cDs3K=zAKC0MnH##v}H`?7}%mqmQYUSNnuj*Y*nLSsNkjH&GF^StbtV;kt`zsSkS#+YEGD)wPBqs!`@RGA z>pllKySur5?B9_v5IR1rish4~XVyEf|KdjOouWZV`?mxP0R0~xOx3lpF_(1uFAvfG zd@wIrQBzTq74F*z6EjL2(AuWB{Xl_k59mCYrKD+3%Oha3$bUaLG|EdZ%1V8dec_(T z_D%d+D&6IM?%dH=X*1cb22N}S%e;8`Mq;E~JxElNHl?=hXk819{7~McBx)GFrwed5Is1)ZU^P zg~aTo(|<{YD5EkD$8tdd3;nWgQ;|7jk6RmJjNS5j{)W7^T44@>=1>A&u;l?MVl5}4WEwKaFb;!iRL*SM{ zY8Gqe^-5k?&SprQ(hPD_s%?p6Ph)gNl(|2)T%oHRI9(ds6h+fiT;)wDO=%tBL=KG~ z^btxBiJweDR!soef04!{_bsB$i>m^VhjZ-1z6tpbM9}i&`f9~xCATp(Y&3%JH>ReN zaWkUjA)O|%){q0d?g3?pz#9PN;OnDUt&?cUWhK328)wtUI_s}z{hFIguL5{cE$c@i z4v*HYA-V=~&5TW^{O6;=q?`rh{2NKabGK{a-K0iVS=_rIR}kk5OOs+b%lIW^R*|_s zPuAGwYhu?MZnPx?KCXeQ!2CGvTnc?a>^`R!tz zQLb%%olK`%6~Al84?Jll!NmWVDHB~4h9DVoDs8g`FY4s)w&|4>@Gv~}#0qK&MBAv}&!h@?9cHPDA8f+y{;RR)HNO@S^YFfgcJ{jW zdKIg{nokfB;F96x$&$H!<Y9xAet=ZxDV|nW>&kB*)7u(UsZCa&_fWoghAd?gd}MKvVct@N=d*o+GR3vB24Xm zu2LkT=}1&$!j?J|8T6L7_9#wYY&F6Z{hhU^4?ElO!@V_@KB?7X*;-yU1qW;pwSE66 zfZK3wUc6W8JT)^UNk1ckc{ol%{~hy7jJO(t$V?-G26@`CeAQxjPFt6rAPhx(DlOSK zpynh?aHU`|H-t(L4G(4_CeR@?@PQU9PI(hlir#3nIKd=9-|iK5yqZ?s*eFNegtGFU zvDw$wZvvQo`VcMVr2sOJg?S=l|LNydZ%u{h@mOx{6>79)B2d3m>AH;E1hJcHiC}Q< zPUKtkdkJA!1BuohTK|GRRl-xNe2lGj!!s=izcw^r3Y3*GGOC%V(u&FnA%klMZ#58U zVu;)w4uAge)E?!u>W|lZ@?}(ErfKrvu<{Nor6x=&`o*Act&Q>iN=H*A{%S{qv8EN3 z5)O#g^)RxQ;TW>&qS4vMzWKIakwy*tVv<8*k4^`Bp8u4RcIC@zfnVq&S4vBRql@8L zda`6hCJ{+HUE-Xg#(=7IIWe~EHx={z^T)X%&SOfR{?{O6RObk;8=h|YwPGqN^xa+P^W44e66KIff z!bFftB6W0+=za9z56kt3N;6wHzAI~_B3el!=gc7ZlRhwUQFbf^!%T-9hWGDUC6CDZ zZ^+sm{U~~s_b5qRIrp#3!-0%Lz>#xzC`B5uL9%^q4fX@@Qt`6^?o>!eRDYm5^I5eC z&RX%qp26!h5-ogl%w@b<@p?ml3@pz2NqyXR)VTY4&+Az3qPZ~(C(9=@h{jd&Kjv4f zYS$~b3>HSKZ~wdW+G8tJ_5Vum^8ZzO%>OAp zr5{H{VYnYHoy;bDugU8`usA7Tu!!e3*LiSp8F*X3=JJ zWY^7Sm*>W;eXpWmR(U47Oo!5+ek)VGXZ`AqCl-12`gPw!w5W!D^9rN1C`K_ceM_9$ zYU}*PpgenwN%cN!zKEE2;jE~&90TfJQ-@qX4BtY9mNDu&3J=R0>nxeC7D4-Y#40(z zN7u`B$YVzbDWcf4hsW7o)qswTT}uW-fEWtsVU;S`0pNA$rn&ue6Fy}9sh^e z0!pyl-2KF7jfkZIKY6a}RM-Ypno^ah0moWbTc3Ooy+V?EshFos@~gQcPI;GCTaRo; zjCz}DaOjA}+$OZ1zIouzjJGmfa{6Z%DO%k@qaV1!80s)UHtE5%?UjWG4Mu6)OA-8s zXK?-NL642hdDm%J!G<;1hF&XeYaLOsvst{^yuoD)-iG^7VKdFrTIGo2|IYu+*AXI|NN3Qv_hyuh8*8Ii;MUfXde)%UWSs!Pu-F&PoKH?TQbbx@5b= z{nGItk=A)Bnerf4%+IX z!_8U=0b}pSC(E4qw0XB)6BY3$l=ZBG6^IR-oYiuMl!Q{gT(pbAhlKG)rLE-ew?v>) zN=}@j0MxQwe}rm-yuLBNSQt0=KG-}!EPvaCy3&@rM9IjJkXP-5i=plkwajm5+%JUp zW1RUzO${jniY@Ab5OaHHO_>?F#fTVLo97mD7VWa~Fd!hG! zudzhfL`*R13Rvoe@p*QmfWE(PYxLRr>#upSNVA3K4TcPs3ar38UIK1{hQ%XasAbf> z{Ol#QT}KY6W{BBhY0tJdEyKiQ?uuQRM6p~Erg(qR`GK&jDb3mcBMh|gZbQ9`m>kTyA=UjAMcMGN8 zjQ=QAf57}dTHSLsE(fJ;7 zNstDFmNej#m|G4G<2ejiH-qxTY!%j$63B3FwCJEs5Z@)Z!(%BIwmXS4x9!hey~U~%}%fFiH8SGWWuPY zJjgS5e}Lbe4lAJ}vuW7DgeBO9PAh9`8(-0rS-L%^Xu6L0N@Sv#1%H*Jey0-r)>Psi znt060fVXjoL$YC_biimL#SS`HAIeXIP827o;6aGkD~2}9g~aXH^LXo99oeo?_2o;C zQZHpc^xt&bLVa$WDY3m(ldigTW!&JRcX~C_5CoYz8*OFN|F|B=>-H))=Hw)|O>tPGuwc@_F8~xU3C(0Flfj z3Jx_tQMmX0TkPh3pNIC(n^0H&dCk4H3Sln?*0?2^wQ zEAO=(ch9j0Rx24_i03!DNI9Uh21o@~O+wsY!fec2WFPq!DaTnx9VEL~BafpBbGnS_lt+cCYhJTp22us%f{?~T2ve)T|D=n?3d z{jPkL&#^s2DIu~$O$a?7>|f*XC|RAt2W{BR<8_CJ(5 ztq$pirRw~BYvF3rth4?=BEuzt!7_uuO=V6!8WU&~^tVTzbg4rkjSxthw;-x*+E8{e zLCy>svW94y`#noS6YB@)0!E8yCAvmUb|d%;&|1tKGHK!WTn9DtlQ?Af+imvxvY~22 zQUa21?hUu&>5k(K_vz1Ud*k;)WGOGKULqN)n=%0-!tE?n=K3plyODw0SWK_ZAJ)xo z!Sf>;(NMTDEQZl3w+SiOk(WQv>fkXc;K0@NjLD4Hl6rL|hC1BT+}7BG=%mR~qm3(k z5tWCX9Wc=v)Nk`bB$?yv<4wk+t;P#TWsK?KtxGOb%$o#vb0;+?2lp$f{of}cH8m+K z3!Q3=wawSR5m(BzY6!{TF2{_19}1={rZOj*bJ~IC#|P8l-*-z&NYm)z(#~E>`A`=Cnn1o@x1Kc<<;y7cSL#Z$OSp`#hKFdv31qh867;#9|>Nv}J+29XE6g7BXNr8eE%@%FU6w%Bd# zZhc&t(QLVGxBrL+OkGL3^F`|osuXBLs6|lQN8c#@L%gg_UAM&^H@(%#+C3;$;-G%u zs4QKN;$CM72dNLOLoK%guZb$s?CF=0@EZ*SGd{Gg!JHBK7E8M1)kjXF>hRY>)cj6X zVMz7&mkA5GA*G!)`DAg7Qfvs^N`bs}F?p2pjY`JS<2zlAV=gNg+T#J==rfTe4(Roc z4oFRH`bi(c8p;T?n>1MF+!S9@L0rrGqD_gU zOMKN0u>VZ)XdVkMl{{~pBS-_4Ti( z|0tvrO*o;h_ILk>i;0kMNZzK)cv?py;m|~A>GF()C zi~uPZc-U|qt4B!pk$K|e;z*P-Pn}Drr@)X_e*gxYkCfNW3xF*74g93WQ>)v2r-2;^ z=#5(5vgosx!eIT8O!ed_WvfE``6G8mJ6_v8k1?$Q9#WlK_#S4wd4eaX;9Y4g{0Lud zZaCCp(s+{pUIrm;ouoWdKjmZcY^-j{WK2@E35H#=7$dMv5v5>1#v#VoQXSAhQtRrd zYAKBa^(-GA+zsSV)smtq9usBK5YwG>+3(D8<-k@3Rj*D=ta6o1)$cI7`7l&GA)>My zT*mFm6Yc^RYSisk*hiucR|xK$i0@sHaFd{LF4e)`oiG|E5zG2FI`%6pp%1>z;btn@ z7E?j7VZnkBe^(9FxpErdNf^f^1aU>_!tC>ZM)V~!PHl0q6tvWBVqrf|yw@f)I8D$n zi+2|#T1L!iYYs`caz(Z7RpWP|_o^_kEb3TI5-S21I+b+5c1P>k2VS9HlY_jw7t+nT zM6{MCh@$oS??UZgzfy+>VyrWbn*?^aYN3vF9(ND+d%7)WI67eWDi$0)UOa?prp4Al zeAxuGN~?tR*`-S8Q$}u)-nD>KD%x*%uVwdVx{gXpaEhkpxajmy(Z5Gz3{1<~amzDYwd-7D8buHEfh)3nZ3sn-e7ZJ~`j z&D&zkKt3roOSP*yO*K*saewgECO(RrYAEqw|F3ahPuPMV#TyUq2H{JQDVu}`;`sDP zL;f9A^y&d@oPZSD_0Xq=l|<|@JF)E(m>VNb8fJTc!_}CwYhW_2n$kembDeV0o1*(P zv3?yDnl2MJ@R6#5D0yX-pCCC7U*rSUMMR-x8HOV1^(F%aH2sCE^^HOITMv!u(yahw^yMm?pq zjtA{YIb=C3iGqxpR!v)exy3I;T}ATp-bKOvkG*yA)3QfLt)v)j&E4L7W^)>g)w^%| zXp@LSuYFqBovk8sMSyN*xc&}(=az1$ic_43rvst4$_i$1cvWf@7=9j{_N8=0^)(wfdsK;w}qdNUMo2xcB}ar;Uk< zPrY&H>~t*_{cEv`go&*4HJKGGXWy#2d7+pO;wiWQ{sqg=5AtniRL|_fL}=N<1F#zN zG$CQfKCrC539%!8!Z0*{8{(JTp$Wq>w!ny+kW#yJ&eDo?UVpR3k`rG1W=^{wdZe}} zUrwl*wicV}{x@e*c}w6G89X0^%V@qYaVsW3p9}8b6dOU#2n<0^8fELOAxl?D{VyV& zRs7%$ajT7DCG_w2j`zhjkhH$HC0)Ra7 z;0l9UY9;^8?h@ngu*Jvj)a3%mPAjL;nt)!G1(8Gu#C$T--pY;#R9yK+A%~lWSQZ2y z=g$%RaY1OC(I@zf`1%a~>C%{6UkEXBgB@}*a+4jRQXAxY4}JKeBWReZD8CfT(DZI^ ze%c*n(5NeMn=v%lS$rphEDLcfcb|j;4&|G@&l~+Ri#d-k@+2&hlw)Tk7Wfv&GZiN= zyd+Qm1`FR#)2wG-wPTXN-3f7bgUN+Wa=!ZU5I`yCn-VNhW6pj%v5sg&f3DIpzFlndSh~T2$P|XMQ za9d=L+-oTNY2HLyfT9eAOl)6}k?n8ETveEY96E> z5KkdrDdL}B3_bCM+fA&;zdkb# zz~83Wm80&1PiOqzK$^6t943XEjvfC5SU|XUCbPmJ+8TM{s*#~DehfMAjBCU4eGhp= zPPAnJ%jc*hacRguJg^YQ{`+WR@gg%qh5zQ;U6>MEchpjAS{V2vaH0JZ*7WFsmm+%- zReY%Th$U5E$aY3nD3r8a^^H6rkf?z1km=^DUgBF7H`lj1xL$yABa zzgO22;#7mlKK zN2rt2yfxd!wq!fg%ypH^<)?V;##FaqTW6y?(?xpYy72Evn(Do4w%4fhW$`A{?B&;n zcbv;@>GohGSFP?C<}HRs)tw2O4)4vgq7AK+@iQsJDv9%_AK3dUPEzxs7pKo!4DS9e z*}?ASJ?`r)4jAT7@XrnZkVxN1to74I+vdp68lCmi`ka;5oXEmR5C)g^VH5&Rv;E4* z4@CGChfQUm=-NQ#I*P1^E`sv%U|4am<5 zJ?{K6;v!ClF%X%x*sOI~>#ki|U3M*>MskC~%?|qA{=_oO^gv`RjTNV^|82KkCcdAq zZ_k*1p>BP5Tuj`()*W7Zy^+Ri*J`mgJuyUThQeMllH5$% z{MyPdnbJqPc7zRMS3bQ?A?i(k-9-8?Ia_q68`Yum!J3}qBPMP}E96t6xT{l<{HP^?05NXak(NFN+$RWu;t5XLm zFpFvWV;-fk#`{T+#bIulqFH?k-TE0%3NuC7_~t9cub$^7qfTDXizqk6>2a0ZJaJvc zLz<>&5dJF$b8=f6{GwdFm4e1qOTU%&=j3vaSLx}WhjN+{T&~Ql3xZ|~WTuU@mI<^{ zYnm?M$xyb9Imryt2f5ZgnLMQ&Lz8N)v-Ij)-asUA5w&uyjGKZc+Y-Qz5y4a3jJ1lZ zA=^SOdy8GjIfV}!5;e+~dGZxH+?@g-+#{?0U{~huW9Np`MkmY;rP^m2p@&<|R|!=v zig#rlwb#qA9LC_jjBDm^F;Da~#ya^~_&ozKTg`<{1GxJB%gHm$JD@KcPyhfF*#FIZ zW%$P_HBD7aWwdS5Ujm_PAsYTGnnPsfenong&3e?qdWJws2`ws19O5PVV@4q}Qf8OU zJGb9pxLP#1bH=nfDGgpvyzc9!4Eab3hT7irkyCFDGp>JJxj&$Kf8Gvh0bmXw{Y@iO zn3WqSDzZ6r7bBC2NsBQ$E{aO93}P7$O&$8_^=qOY$={&PS3eHBGFDSTF+M zUYRuOwJ3@rQmmJ?EI7)GlPx4zwWnE>36m??Co|M1DwYMru`SIclO1_d5-sv#Voo0o zr2$B(69uG`ISvQk-s%Sp*NL5lrO_+YWu}o+nhGY_{qr~NNrG7Xe}Av%!*KvFxDx5( zJ3%;|okS?ls|CFdtiWjm@wB*JrqCfdfj41V@G}AniQuJ0Un8!yE2o}}IxD)uLQ0Kj zDit)-mV%*DqN&sqz*z^QjBKwpL;u<$go`xQ2n?N%)&aRbLo{=ulk6ml94 zYqm0Tun7EUeGuKN@!0#0EGb0ACTYdTaTVMPKO?uiIokT%8uv0`@n&&la3$d8 zOwK`AL$=LmCrp#OV~Po}M{Sv8P;d^lb=Z+C0M0>KyP?@w31)ZHSdPw^F7%acT+!!` z#V_1L=h|F)&NR1DXKm9-L1}Q>u0kKtntm>6ULapySq|t2%^VJdFrg#8+k=iozerLh zJguln=}}usF}@LSlfhF~f+oDsnE$m7qHK+)F}GHu;gq*C2s97b(TS%0Qi0emJ%41R zF!_DYU9N{?6h3xhCg_&4CEvvCffr}H(oG#+ckIvY`cZcnK1dxuig`&YUex7il*fsj z@>7iII!@gZVwWP+U4X$X!u^$7U^74~JN)yUz1|e7+ZW)Jo3zr5-vH;9#mU&%{yB7I zl=0-MxTa4fup7dVGT_GdM-8CM6L6C7fu2IW81FO%qyzJVg-$r{0(%~BmSUaCZvHSx zS%Ub6x50(I3YY^(05fJsfeOx-aPvCm~@>7;~YC z0S!BR$6*i~Oh()&IX+Z01SSfnAWOwI4-VNwVEK_*U;S6QdaNYox;+aeO(&4nmL>zIA zTi4K1wOO&FaB(2WT%%_7M5t?3cQTDqQ-yvY$I28l2#+(S30erxRzC**Enr|Brsh%T z@r}k8yQE|ZniRA)k93~L3A(fQnWpaLHELzG?)S@tCiIRZ%xaMn?oDa4jXRfDRjzqw z^x^r?%UN!F%(yzlxouarsNBz3clRd_Mpw+C)(yG@QAo|<5b_i0Mh)H<5NIG9<0o>` zzH&K3+dJaP?A4U+;JNN`{J@8oOW`%_kO13Y?>9M*A1vFp;77I?Va%6TN&GR};JpY@ z=_(<6w7y9&lpi~kd_ug?D&hEt`uROUIN2(E?Z-uXD@CpDY{68q03|de#G`^};trG5 z^J_~7F|xh1#u%L%l!rCzrjHy!!1^8N1JvDW>UQ&oUm*{mA5N(&3Mv;2rvxS0D~4y6{9l zPZZUiFLKlYz3o;k(9SCxjDwRKN$%YUKG*pj7`6jl$URg}z%+>q3S3c^KPBrDn*nhk zWZRi_dyp#X-2NW(?^;f`!T!u3`#Th#QpnBpyVf^l?q%2Se<{S|);i7R{?0w2001Qa zw>Gx_{130Jt@v+F*f9J+mQ`V70oBsZmV$gMdX*?;);-$lCG@i zU&vq3-B;P!<;mrY**Y(x&hJ^$2K)g?Bau2+Z*DK#v+g^!kKfNPx4r;vFq(QR4;CWp zl^H$vg4-<>nEv`4E$C>iK%|u@Z*r`Gr8Uf|uGHGC+M_PUZ@Thko;5k)f?!-3I$2a! zY0!&yQmqDCwANyjSsm?~N^9Px<1Ujd%nh6Nw#>nbfZnx+L-W*hK6F`ZF%U+7cor2P zRVMu#F?hC_Sw)Wn?W4*WB2K@~TtXhM1#^5mWcIA+$&mZF+b#Ppi> zNlHXg5a;Zq_RgBFH}<9d6+PN)epl9Nd)`2|LSW5nH=s0jyQp9b=N&(*P z3DI`Y#qr_FR4k_hzuQYx&p-?*MBLazS+WH<0&Yi?_hrLu!Nf}xZB%|NB)AB^7g={g z*Gd%L6sd6h{oo=^K6ZPEwe_XlYtdYk2xMZcdkg6u!-*|oBXlypkCW(Y>pp&J84+mr zOdqyjO6)RY7rr^}JXJ2l3YOwpYB@?AC;8Iu6GGz>2v6=t=qksTfUs~7-{=oC)m|22 zp)b-!Oacmeo>*gnW~;>SBT&YGMR8ycd11&#JLOq4Ge>2=Pi;VvFlqHG|EgaKlm(t~ zb?K}K>uYg)boOWLXW56y|gJmh!^f3#)l&n^j z-!rZlq;&PO>&^%EZa=ZJJz9?U&YcnD`USq#pNioTZ`hpf6Flxx0fg&s$U(i~Wb}A1 zedCJ!*jCLzE7)iiLSn4lEE2WdA_+~;><2!Qkl=!F;@;3p|jn3WUXGIYLXk2Kn$O5SESG3+@sEmeA)=Pp_8Y< zBe1kEY~;yY8co7D&JByzcH5%^s?m{WOk*;_z*-~iz*tG|#;C)7;fnLcr-@L z43s8*d#sP8=_WWFK18HYc8*SpEkxcRF zi#5ttWoogZmFw;Q8b%eUgvAt8ci%g=frp3Czx*d$Orkx2zR>HIj#F zlfo({;8|UQkF-tvd%)J8 zp{+HP8y>}Q=)^JuU4odK5c(?kaGHkl@%?nnd~=u?L{ z@RIO1APHS!3DT&aTLkH`w)#iJBDkfaI(l89nW4W6un)l%PHW?{pFZ5b=*lmODiWBr1;`Y}#_ ze6RBbGEU8(ycKlS_^CpFvs0bO4I!9%B@Z&Jlt02a_w2u1LkRiZe*X&*bRzSE{BIL1 z!#@dD@!tvdyM?-X4$xX2DOuwMwPZllLWZ}XbwUjdc}SlZ(1 zFTkd++xEQu17Jnn#_kCI9bmg||33gbRY84vC4|$8>-5UCl7A{*U4(MYTP}uiZS59~ z_3r?C{gHp3$wrU>NkF#0_skiXL+ecRot@6^EH=Vqn0O-&r)a9*jh)kapiVuYt(Hdl+_X{6alDe=IDtqHb z?F6IRBep{%biw12=_M@#MNY^ZfjkL4#iEcHi7s$bacmDla3owGY=m&wat{2I`+!4X zpct*9@QF?;mph2Ho*SWKx=stTr_ERA1?j=&YWOe0y6jXx(f>u*i674sJ5vaCDTbr2 z$5922)i(v9u4zJ<0flbOT>-ptriMIUvTZrTpI! zc4>cZE(@~OaA672b3vkFZDB(woF@_#I?-M`(oV5s#YQAg%_;R-C_V*!D#4p?9x)=j z$jkc~$?>_FHti>E?0H;ew#5#46`{aOpdZ2Z*J^oF5N6w3W`+0}QgviZg+i^bK(p7! zE0XfViP{%u3eO;CJ6);5EoVSPPZP+#eK#IXXta+6i%iNc~db*+kG$GJsIIYznGwEFy% zf=V@t#D$xnW<}P0eU=iCvlT%ut}P&&aXQwLQaK}imM$7EQ;xQZ>T*(k#_^JuzB*+` z79!gJVeK7*H0io^-Ex<0+qP}nwv8@#*|u%lwr$(4F5~q3Vej?5=j;(!zc_OC=Kbq|zgalIf zJMvJOh4_s@{MS}Pd#|tBnfdZE+C9bfDYx?xtGqb`TGjh%!%_UqsT%2RN3wCicVYe1 zxfd|GBW)ik&Bb!~Gdc>nfYL5$EVW7VsYEMLgc6i8HhbdufLfNIPS|35zh0g`SSe!o zcU-WH>X?$V?y#YZ&g-_t@UN09F#Cue|HYw&{s{YKJX z?rS;|rkrx22#7&|+Nt2f*V2`k*d?1eR~9t$RI`_~n+n@050K4Lm*Vw8jf3F(!Q&lM zD}GskeD8-SwPynTs4USx7D?JM%u0yYwMg)>Nci*1rlFZ%C5Z&+2fP8C`_OF5=`m;E zh&51P3(k$eV=|SMu9wP}Ju_J(cM^RPU35~sumI$_FT?bVOp3xCHej$BoK+vGymgeJ zeH%~iX9nWhF4gSfx}B-uQegUUx-(blqCQJBO8ygbhS}xQ15;}braGl4h+2ouDwsjf zsmr3uS+L2aNx!!5xK^%@`WW;{ry(^@R)fW{`rPu&NyJ9vm^RQ1R8eG!KEE0TCss#( z2`nEp>Z)Ai$2ku-JWoZ3)ErC=XWRy&j4Wx>CEm^fBr1P$sC5);;K?YCh*rT5pjXqa zjd*Y^+*KwvIIcMMMMU@fI-gA8aV{~ib}fTfy-wGZO?@ly5{bo%as{JBrf-UK(vN%L z551ioXiZ#gs*BSu1JzoF8N_c^yhJ0s9$b!`Xj?c0>PY zYqefzC~utk1riZp_aBA5V_p7Zu!^^#_Zw_~3^q)7mL>0x!FK;;uy%F$+}mRh*Bd@j ze;Mqs3{JJw33J$GqoD5A-jh__PN&sNrhh0H z#1UbFB&5JSft3}ir>UrYJnLw%?rK+Q&RYALipkPtlwZ{8u^XkQ^O%%I7u+GPCb32Y zAI;Laj>ZqMDxZg?&mB$kOx2Uty5$XDS6h-fixMO!qVmcrLY*DJrCFaR9Jhm>v%M*&K(Zz zgn|wSxGI+kqW z%Tb=PD>RdZ1}D zr$#`#)fuSPOh}0pIu4dED4doE&%3{28}0fN)3Zxh;*}xCCKKjeHQ;410qvIJ=-W^D z76ZpSfR+qHbi5B-6z%mJa`-lv46lD6+Y2N{k&``WS?LVU@K)|F9EHLb5)^Vm3?ohr8I=IiNBKd6X%m< zS%uW=(~3GWus_K@*M0S8=K9M{9$(t{%1hi>p->tm_g?bwT#u(WvQF9_Hq$;YZvI-O z4Fx;Uc*Y!Au5?m;=d{3hftB576X7}RJ(I0GoLS>^lg4b8KELQ9wVsT;R0Ity{&YaXBugS6uO!hWnjl#fI(n}&*j8IVl1m`wo>`^;9mJ<=>iG)#S&B$~2U&2D+{Of2a!9&xn?tsnG& zXksUvlk|Hq@9b!3ut6jvcf8DoY?`*@h@1+DMt079R-*HvIelS0qsVAIV7+xHHP$)L zM%lBV(ZaP~pFIO-tK@UGi3h|Xdi-%a{RLEX55Fgawu~5fE|D2>z{QL90J<%`HkUXf zr9H$ZA^-J!z~E4_$1UgphMKxnaw+dB zvMu~obGSLaPU!I+Nw*?5DF#>}{1Qcm=XSoMA=pJis^S@U@H?Kg?W5x)y>!uYhs{?; zboVlSME~|c^lfr4_JBshhHKfG4Wx#*_3MSyUeL@fC60p@-Y)sJlU=LfRBo+|MIE~Vq3nTpMm#g!%Vp;NJZ zDHH~kUxQ*PN-IiIP1#J^>w2URAN(n=)%5Y|DmB|}Su5;PH4d&PEYr*0!O{$rDN?cv zEJNN37R$vsZ~pb1zqYpaT@z?m^rRjV;F{bxmX3j9G>a40>r5N>lxcrYgZ}u6`lPLwj zE?uAz=-N5DNVM*UE$17Ep||&W3qWqV52Sy(4@4rj`3HKmB1P%%Sk=KY+)?PB!L!Lx zsm!U!?m>N1D7;V9QS+#AX-`<*VUZ%5dR~GZ>e|HT>R!b!qeaO*rE`UCUjev1!22{b z_O4A|8!R@xgQuI+(UJG)$DOTVo@Ra_vmXZo1skb8L2D+Ku=O-v512!7Hr!s-%2Aj} zYUC*ZvK`9yfPC{UlH}}HCi{rLb&9*W0#)~91j=}OBG_W=>d<$m3Ia*)TXA2ygw5#^ zLR_hJ1I;JGY@oaPZyimIq1JFMxm91#M1g?{ei*F6okf27W;1v4S@VNLMSp zgE6#8c(;_^%;AzA>HOn}l>kC*nqKIv)*r_2+P(5%tz;{WOqj_LTxM7dNFOME`_73m zho6iPenSwTtHD?8Jpjr^u8q7Y9hL;#5t`3DU@j5NX#+aWd;42!8sC+W?l3R_z$iEX zfWUtb%YUu8|KCLZd!uNV%D-R?;74<#LTyN4Ioqketd492AJN%JwI>pPoVhGFU<#gK zZF+WX9sQ~Hl)3EqmdBU-7l;k#!!>5+` zc#?~A#`-;boPC(rh2UXj+EAsGKh&ixNhwFEVB>_aLYO4y&%i#!Y3HzUP;PpIvm8_b zH56PUv0UAf{-jW-P2-KSu9c0d)Oa#1BjtF<@8M`7_%oc{A{t{?lFG5+SMse+Y zw^<5_(eERVH$m@`V(Xk`XPo`mYRpM6Ch;7qPlK8rnI@~qw( zrK5Kvn%y!3yp6m*=)^VbVb=yt{m^Q=2eql6S_z@^^(rjiM^45lgice;#6aGr1MyzT zotNG3A7rp;II3It%p8yF5~M(um>|v+X<_&jjg4Yit?gfrxB`CC9v#pHtdAef%~{(L z1v!)|GA#(^hOX`o9s#}J7b~Wy{46Uxd%b{z%M2a4U_pl4)G>;+c-=)d)xbbnHyC@l z$%s9;M9zBnvaqV*@yi72`7YLgvigFwqu4d@Sf#q{ti_Z{;6^h?1JM)^a3S|0W!}C9 z-L}(LK3=_~kMW@|mo9xb<~h>ZFrvNmz^tzN&@e!L8uehf^ia%?ignOHdBdv=6b4zt z1fBjtrpirmU1OtVI=#Mm!?n`dqPt_G-tpn})opNE=CfzTXlxv{>SO97>*(XYpfhQ! z5xm2thf;dUE5#E9SKQ|`tX>0sI0B^4c99!d;KaC<*ed*jJohuSAQ60-2RGPX)YVI= zEgKIpTKT}JXI#H)%vB0=O3I;ZA!iq#&?_UKP)GuIIq}{F%c`Dzqs>XV;sn**!{|q; z(P<4Ug8{D2g5+R~VdOHQ!*l2jk)?@Kj{)U77wz}_bsdgo$<8Kxmmy}1Yfc>Kxzgk= z5a+(T z%;ZUHoRIbW2sPaO0s}^1RdKlc*o5njcPYBbFqFVUy3Oc%1+Bk<{}U2f0!nQ?e>IQ) zk0HVMZ%Aw?Vu``?1OW5}N8q!<4il^<^CQAq#SIdp4)3K`BB)Vx-ZEE$G0a;gUrv{2 z_BfU!hKvjG;^QSr#!wgaQnM$8a9?V7$hqZQXMaA_?0f^%1`-A5U$9-3@9g@08WC@a z@-()QxtrSsZkKCVUo@IK&}Ai$CrB!9bc9;P=}P~BMA5$?k+m395mEGSNLc(E5-J9- z?V?4B+7yh1+dbm>DF$z@t;BB_q%|Q9`p0Sph``XmIwcs{CNV4Rutij6dF9!=Y+40r9p_5Q*8PGi=)rz!Znbdg?`=#q0oZnQH0y=Te=i4 z63J~fr5NQJ3sdOO>1_H&S4 z6{-Dxp)EI1K^jB|o5t9Qf%-2#lylc*iw^hWn_}FzoK=a;7nIYV4>}@h1S7~~bSa4i zrBkJpa)(m|V#$omZ4=PLm*C>8ZiohXO&V|nvZz)i+V9h+Z;aV zRZ%-8@sr$sD40FWGNYy`*Oovzn0KL5S?%N~Ysve9GO=lExbFTa?b>o8J%?hllg!mf zJ0%;8O0OO=21+g&QfdZD)n|dwaQU;Gp5jjD8t0Dx9!XAlvX@?y5Yn~aI1HSVEWkuV zNm~rCDg?Voxc2&v9(ggQAY=UZN9=ek;?H9>j7qmTTZsa9GFoSk0XU-l7K&(N^0EGM z#co?CnNX!HF0rQSt$cnsbG#(r&lp9Dhx~NTyia;jbLu(UxF;TFt=CsQhU{+`H(y({ zS=cC~8(Z^kv+{7&9C#oU0#b*Z>qwu3+bCsIpN|D7?&)v;yOz|9n`yKc?j=53y&^{{e9s9DKE*Um7MAWaKuK4d$Z5y z=+uKxJO4=s*PD3v1|R@{%RhD}^xva{>3>L_>HlkO*p$+iERq27S1~B$24PXYKe=kz zEZ(IiGlOCvKA(h;f>6e`y+Cn?RhlbT#nu)W-mqJ*u+d}xkK4RRv*cxXD#QLLG*6#n zuan8lo6OIzg9|nQx!csew7o``1&h1ruVpmRinH@g)Xk+9ZBHSL4&C!6x+rByaV_x) zXl^-nMip@sLVakeBhnkkt>gNBn zb$5}bt=#_!Cm-dL6!?^Up*fNsh~s$I+XGDw7FKgYMeK59gpuo2cQ| zv(#k6S%y}f1Dw8#W)&5GgM8@+kG+-))e<*GRRowZo*p6BO5&oWwZ^s!%|>vfjrm%f z6kUEVJPy(TSIMbp`7|Zp{V)U5os?{IEtWmVb^{^Fk%K-=#>po`k~N&re10ts>4gG3 zUR*V+@?ps@_UhQl_!eoO(8lB`Z$ZL$j1^vxhv~RZA4~#oAsRr9a-gfob?Wf4kKejJ z%_`90wx4iSXVa6pgHJPdNG{+D(5yhyuA%M!427L_3| z2L`!lr92-e6+$0CPK(FbJnz$h{UpRvty=v&lL?~bf$u+s(OF_d-sXSk(=zh??QKV- z30x<`pX*wP|8`>jpK9V?6`^dSq$Psv!wLx0vR9PorYS5=5}0Boydi0xbe~T_An(5z zK5lKBTzhD0*Y!-Q#P`nhD7J`E7dg%D^CZTR-9?;~j_>@jZ(QGDcC+S`{qXd0cS6?# z*ct&GoOqNt<*&3~{VF4(S!6VG*nF7XcD^?8R=YO>OEn%oR9Ihx7{q^HRA4v;jxJWgD?Cg~!{aPCd!0=iV?RHi%nhw^LF%1bhvj zKmx+rkf6zQ_m5))h_2?+*%$g~BG0b#7qpBsK*?-)kTV-U=_L++c9?#03Fhn3PCm+J z8*<(uz4->Wtt~8Pa1Z^UmO=|?U~(#HeWpUqiD&*meD)m?D^Yhx;P)2109l_4Mu0R! z4nQw<0bx6Hi;y)!CoIQ=;)eyHN7ePy&P}NhVB{q$OX&{we>d2VZTO_9q8U|0>;~@) zs>8dCxg)vyzQ7$)3$%~S*CJZHcn2@JlfcX=jvFRguZt4}(@T&=JG-3aYk@m9c>de= zPq^s~i~n~rn(=>Uw3Zx_0{oXO6mqkOqzI@&rD7i1pNx)|4^*c8M@Dzw^2@rdIIdr5 zY)J+14S)X%8$J!->%}w4)X@~84uzoOz0{ueVmg^*YvA+od56^pmBYxkQWaaOVr^M+ z%bs1Q5j!g0jcz|mCD~bZQL^JMD^e6p7sT|~ZR?LOJ5nY98m`r}xT95*q0Kn25S^Ia zG%T`d3slwGie4Y#I+vZHtX#4jTK9428ioI6V)G{mZtFP_qwkmR?4KY)H$g|Jw`zh3 z9(wH4hf3=!Kt~p)u#*wrt7e)kp*w3xEkoj7qNk~vitU8P835Tg0yl^!$edm@LLYGf z9Y{4qcZ>-fs8wj2?+m>SdgS{(PM=;(VPY=rv$3EcY@b0utQ|b)emmyl<{3QUa_z_o z<JUqqO0&15kI)w&}gp$4@RkJ{U zxjm+?!c5}ElX0o8Avv(k+;)KeS41l^(S-aJ(Hfiqj(;NhmZ@a=;ID{Is6It^*#mFS zBQ83+*N4F{^{lJEgcVlEt->xon}frPdAB-`N&0ZmCQ2i*OZ$MgAjx=-6n=uT^@2Xm zB65FV7JN(Y0Wp3>F*o~|%1_PdnVX0)aP9>ufcv6SYfw=8f{dYz?{5_+$0sBpeCtCG}f&a?r$dI3c=)SlSk4133*v3A8 z%V;`;7WSWiu3I7hry2e4{~=Utl(1BgeQbe1mgUKdMk5TV>jDARiY)~M>k?DaNjt^K zQ55b>ogk!G-5lH!%62~R-iXen|H|mk%NR#)w*+NN|K69OvBU05p6ASy%!f>;@0W*_ z9l%L``aO61v8+p6vieJzq?J1pjYy@^UkinaY6={ z0AktZI!&-hf=5O4`%eYz(12PohFu=ZmNpDF`Fk{B7(k3Dg}2b>W~1HE*O8fn-)Z1= z(py1lsm{TLQdRcg)sz)vY18em zAy`aUe2dp{2=+<5^hqoRB$Sp=&1F_QzpkDS5(~XhPtq4#u_8gcRmXc5(FSrsuSE23 zkkU&v7t$vTBsL3M=Zow|1_yo>bH^Rf@-D^>ia}d6lBs_FP!vfZ@EWH_?T{XGGg24; z=Jrb9qbU?pCtZQI)wpO@I$l;<;M%NyrV7TRTcMhcvV&@8o8{W?&F{)qP@}b_me@$N55|Pb7jdy(lPS01Mt8|J1ZqV1x79YRn*8 zqY^mq*E4}7W|Mo)FsrrEkDWJF|J|<-WcT#%6_6kSaIvut=)CD`g4AJ6%nJ)^{RC65 z>|s<$;)41Q&`-DLUTv>ewEx5!_O;(xF$@3z0sa4JANs#z?Vt3z(s=bnT0zJuv0Ab6 z3#tWNLx)D~Lt8!)N-jgf2ZIKSyo&X=*lcP{Z88~~lcaqVH&0J4v{+!aNGG)r7taXZ zWMh}eKrA_nW9_=(>WcZ!JL_zzNJ6c$Nh7*~dtIHM?)J7b?cPaqA32Zu?tFv+O}*F2h$}cS3YUwV$lvc!471DcpJx6M_{&N<6Vcm&f#H0EiIG&KZb`Bi zGXWOiwBmwhu|7rDjXbNX%@6o2F%$jj5=XB1z_Fi=m1U1BQ9^jZkumS3*-|mL!3WPz zi>`b;3QM{%YBVgha$v1;8%8)GA-?*$JTUY6(h!y<)%=u~k)r4jf+7y}Al)v5^hg${ zscUSp)J|G5rcf9I2LUO}SW`J`Jxls#dhlR%qK?5^UA6ruJb2|eFh1G5A|=7FHxER( zY+M244_P3TxdBCIZLA%#;z0-qE<|;`6;E4xY4Y-&lfWT)(q2M)gjy1NW;k1*r?{Vt zoYO)ae(h@#^QQ{IMv|GPgN)92Ms#GvWR(CR026&ezn;gpg>;jV3PbqAQh=0iDvm?R z0NwI>KrD}?kOKuWAh0)9@hA>+F9=U&<@3UZ`+R8l1cw|?rp}+h*h`Bv@QspP5K=d` z+^vQoE_N9KJ8QG~KQE_^L(F9ztK`J5hXw#I%!{z3r7-B7&ox);+O(!1WiX#sQ za1T?+-tit;9JC$uPt(edEnH1*EhYbq_Y#i|r}VPIDJlUbY?Q6jI{8>&GU~lZLNXT) z$C$t1VWy~M@7Q(BVp ziibdEjI@qKic%;DbDF%fpF^oaNgjnrh&xr`W02o#z5~rmGa4 zdNdDOmx6LZlbxgXhL(q*Rw=~*&v~t_gGP)o5k~{04Rb>xZ*jOGo+6WucmOgFb(o)f)XP1}bpgeI)H=ni4h1vm zpJh;Y&geE~yik#L&5M{sdS(w>-6w;G6)}EbPJ%F1sMxylV1t09K!IzfdjHmgr?l>c zCI;LpB^@P20V_(6jYNJ>R2`zK4owW0|4WM)4F-vm?zg!R#3dWmK?EWi88%p2@)P>? z5|}%`(S)KiNxY$_z1qZZ8SgIu+jHD?QCG zAdCauHRI2tU0@JaWXb_>SqTbbRN&IW=R6k+IZO1U+0RP_prSc#CcgmF49h8dNlxSu z&mCD!!w0*m$0qRRP;*6zqne-z--)iYzHEVgx;c2hHA11(YS?kctYoMem+(1Kvs zxOE_+Fx|HR8lihtUQ`-wKU40N*@dWbm)_fRZ5Np@!|pL*=EJ zwz8<^sHwW{#4dUm)N#&!=?6t|j}7nyX-!GCI~$#AEHrC2F=sk9ZVn4T0?9YUo^;XV zt6_sCqiafvxSh|lAb@0lLG)8?04)e@IDq_FirfvY*SB@t-#JWS%9u2WvMYV}`Vn2X z8Irx1=$m-9J!&}+XyT)IaU3|JjEOrlT}sJpBdTJDh!$;5U}gfuhUWXLUD(!o5w5|D z6-f3cYC46=41&2f06vhL&g%B({&DnPp%0*$P3HD=ZI~4S^$f$0$bl8H5kKEQ`9(Zh-|) z(O)Zj86e5VS?eT4L#e5#4BW|K6jT7}rzuH3fVRKx=uJ2X7r6K&>j3JOa(G;$?4MHg z9W+2DAR{PgO;b>kH$z5ZB6zg&Syu0HIcvL=FAh~}hWnBTu(d7j62KyM5S?2q$v6CQ zv)$IHQrc~7BXW#(25(Z`XASmVub4(&SBfEEG3}FP{j9P+){JNkKrL#k@V^uTvPHLp zJ(k-lHgQ`9rn29&N=L@%93MKDYz|)Ahm^lv`))=pwi9aFs4Fn+#;9G#({NOjH2ipv zTPyBst;W2*Tp4+)xYI_A`lhT@lh3<0!91()B^h>f8OS>)-zXca_fuEGqiXoMY^qI? zR=ul;ti+buGS3d(BdmgG-Q~-LVK&ktRFbTwLaGOCmpocjUOD<{sKsdAs!Xw0QvnBF zmaIF)#59g;(qyE%lHsHy^k?^laWs;bXAzRP!liJlM-K%Kq{11xlQHmUX^%HwM>hA% zv)cOlAzXcquXA%IGaY}P1tJhTV>+0XWPISIs+wSR#1s|<`d6)$TwcZ`$np7^tjWH` zIIc_iWTaxMNHZ_EW&9qx)~d5hTQ*=P86r{06MIf&tz(TTIg3d!rOO73Pg7QISHv8P zJl`&0QrZm9?s2h*)9F#OYsbS~MRyIj#$|t`@$)XMTkATjy8GCBcRO@3Xh5Eh9oSKZDoQ zRDgaV@?*7zT?|h5kK?ye2M@9oT0KV7)Ld+S`Zc>Qy0I=KsMWE&Od9xENki>yZnVJ_ z_^T&~H5gcMbVql?--br8mT0taSsO(CcA5z~lyp%OD;GSSGjN$6Wdk1PZA12G*(}#UiN_g6I}N6I#N3_EexWJ) zZ2<7iSEyTozR8se%=Ns5`uwSG_>RmqUj;3Tj$1WIs3&BLKv>&LgN+)pWe9Xm!=oWL$-d3($+1Sf47?wS!_TVcwZ zKLl(P#2W zAc?r?A}v;57@e<52o2lv0i;OIsej{U3;+1jwx#IqEy-XTVE;A}$W>C3E?W~m; zKwf4Da*;LwE_MwrgC@!58E}-(h3T^iron6_LxnG>vW)bY?w?+$?Ep{%>EaIa;SOz; zB@7mudzTfed^)W=X9`==X35#p@5=8#2!t^0(vp}p!NhZ zBk+|05_lrD*a^1$VP^9qyP}y?mFUAU2jE;HAUU(AYhWWR9?g9+exmj9GKg(m$)J$75_^HasP^!71*}KrGu4268W2h}LUl8^(*y7G=c54KYa)m ziLP!YV>zsEURRx|U+)Ho)7sno%^EB#?tWnt*1fHIutRaI+Qf^ejCFkdBSO<|ZbS@q z_W&t~cX+MLfZCCT|>~FiS}*UH|WIZ@6*MF(`}~6FnU@goYbZ^6XT-p!)F{md7g~n`}mvd zT~hF5eE$FdP(%R$p#E>T-hbVI@k&ig32P1EE4q3BKL8$$s7}L4!5TgVFl_?UVEI>F zTx+Tha=lf2Z2@5yP-_t!fgfb?DqG#k`WydpJTDaz49xi0%^3-Z#eid35& zhLiT{HrsC#F6d$SEwvcsA^9nuqq*sB)m#leAtQwi9G})yPuAB<;NYOIB##)pI|efo z)oE<&Rg|qwE=36AkKjB43%s?~Ch+!j@^nUAnTFfWZ zI(I@9v&T-D8d9zVy{V4Iv+HZtxKNV9r4(cutRrxnkuQ|FGO6qYRR$AGIkV~ zN%$w&jOHucH&J)j+ZGk*9%9nNXg7&pIm5`nqe0A#O7yqNlxjqIReX7k^I=Iv>7U~M z01SWmbsXdJ#VV=j5Lq`Fanf)faZ*orjwEDe6Uw>-=E2^;J9ZNZm>!drfMxMWLMp+w zhh@@5a5nT%T9Z)ZRq*9Zj+r}#A4PyUU?FiYLK2Uetm{M6^*rEV6;fq&t2EtC~l> zd|1hE-$eI%e zoWb?jj$)Tsk>u~Hw^4RYgS&yb6*HuZaJZ2;yy}Tn;dF5is&`*+Ph7&s98KIj>ZLYGiuVUcoQHTTZ0Nt$oA zt%tRyk!di?g!Ka7Ad)o%z|JeMzySooA5U*~qenb^mOUYSj4Tv(*zgfMylHNM4?|ie z1gvC0XhCsCIl#A`0XIBkpe`DT*w7E{CmpQ|b7n4@@17QvkL*l_IX~BW?4VA(*_>QU z;znx-#N^!fZilmrHh(a4H}RIvXrUX9_oKBi9O4V_T411a1oeO*CO>3+EEofE=T1Mp z1_*~~WGralq9CfYc$M)UQoJClj~B^o&|T&*{Fyj^BVfTG_yIJ1&j5cl5Bk^rbL@9` zSi#p@m;|1aph9;uE%nMGEl{ ziG+iMqINuTN@2GX@sW8DrUXYyVWyPgGy%nQO@cChCvo(cZ|Ly08iji9Ni9Ah3p?{B zLaz7>;>=98tfUfL(2PS&M3bk(jz-6dP6yfpa*>=kc95Ppwn(qUQrAOSKi%01N?{6z zgQbqwy*6*}fF^BNYb?EFV@N(CNkd)oWLYo8p*2i>LPlxtHZi?(+#ZauMI;i{L|_h= zHkop#4e|9Z$gAB}s1RYg0fJ3=aR46soaE{t+>qOwlAoM#BO>pRk>;6IJn*Bg((fUQ%^#{mKW zcn1amVE+G{NElk!8e7v zefpeG18Duh27dW6*Lxuf7}50{b{o)r&nf5{bm{(nUhsGk_Sh79SdcSD4G%QqrUjSo z&1$SW3jbWcaME3UHC3GMOPX`P1m0S*Q6EcC`?|7By$WJY5Teiv!QHl&-QWvo>(Aa)yI{uokGnptpR87r| zgU5sITVIxeP124#8Z!=c6$Qa^L+SHa7f$s zSPRBc<}(nkh5U})l@}OV;>9dH5?SdysJv;DX`V@*XNKpC_fOznjwcA%kDr%d4x^;k z3(7Bh;%!0%zyx?(6`Lvi;Fd(JMz({K4^#Go-a7sw@;4!M(Sgo2ZKi+=m;S$^&5|i( znQ9pU03nzR$7jvr>Y5GuDEc(gnX%UkVXKNHWKr2<#v4r2oy{{Q^}kn)*N1(li0U~)zHWNl*4>ntv(n>IbC z=N#Yf52#)mZq!xq{OpaecAv|y)biX8yUpf#oTBGVPYxD7XXzJm2QR1GfGXu5Vd0Gp zm*hO5?eiM6Lg1}iPUM6K%Bg zE9=icy0P8@z2>)51m;JhliNI={BIef-#x6rC%xBXpK&J!Z(hRWnD*OxA2Zm`BMY`= z$YDm%zry(i%r<9`DJFhJ?&&><-|bsF&Zanf;~N2?i@|`HVjpq4fzQermz;&<^+Jkt zH#kc-GZVW-iadfyy2G|XfY>%5{;ov)Js`OVN2TD~!cq=b`3#Dwt)xkE0kos0Re;)j zmrRUDr)T6Kwwp3{Rm0=Cu8s#*-^^V9Suji-?$VUlDL?!)cYaDyw#%3afqYxkdvtya zqCy`-l|Y_PDc|@4H1!N0+nXpv^(nuO&-1#8y7L4iRmW_R zs%(!#g2l06&HcE+Sh)*ET6tJem$H_?@oh^s-kQYrEZ&!L7u6eZel39(qPkZo37S*D z5T;TIk(kBJ@7M9&kircCbWY-_LR20D1eUfCK!o^K*iC9Q{;f(lGx?_WHCPL=_rOF z@*AeAfLJP^k5Nj6*fs6jA@MSmC}%+_b@a5bh^e-TG^=iFy5Dv!&%Z<4&iENJaq58Z z4EXvwc0Kja>`%`q&-Dt9`YZkSfKc_rD}rTfI-oR8dgCSwwE_#94@5)hz)=`Wn?x1R z8Hbf)k?K1P+ex?2D<<0*P3Zbg>nGQBCnpd`f*vmrM~KVYOk@9nZFEUR+K094%=}F& zs?Fyec>rG&>KR}gg{V3MvRCZ{9BeT|!kotBRsQMTKmi_aey{#!uC@I*y$hb1KNDCE zx|e0>eVRdzx54R*I~>|AO~Lx=5g9ZZ_htiWdSUC0!NcEbc3`hb@&HFbxWDmFy#gQu z0Py|~YW9D<2av})LHY6lq1H664a*XBh%zYvq3*2@6|xc7G^CBTriInV7DQ?f1w~Cp z(Wt83VolMFe@xe1SnkrB_b7&|;zF`spJcwa(px_>`g1DjaW3$7D7}4l#6z0{hCJ3y z)_(uq>~@;=jrRG@+4(^2{c&5|Tjp%az?-Epd-?LfLDWT`6D5bS+-NX0JmX>H>d0xO zYoD+Al%8}wp1$YyYuzryx%uzh?FD?oe+a5H+JXMT;NM^kQ$7Vqo*4=bJ%8l0P16dGcpXK z>k9|XsYNDfU_k?4#NzH16ZjFO;&Au>q8BkX-3#?!^Jt}K!AJyAu z(+Hi1nTJ1K8`3LWGAZpLfM1_*N3dvEQ@BnBZ_dezDu{wBui*CybfgICcgRY+%^QDu zslT|q&|X)$u{d6v%mmfm+eV5v=n7DIpiU9iJ4nm!@@9BF(q+{WEXE^cLffU6+rL=s zti)d^SUR$Y4@-dfGtHgywo-3%i>#Fk=$NxXps1C+ujRM(v@^)LBn`}-0<&aOI!JVq zBMbEiYh`8J^sv&<8ECPM^lqQV))_7)ZaZ^2=4BRk8?$4bTWv{;n7(fUTp>YYu8Ig# zG}PiTC`^Zkgew_{WhMuglga4$0yL8ulQJxGl4D$O(;fo~L?$x0T^b_fI_Q%^-<*{c zXPyB(XZutruk4m&*?bVzUDR{gZ;DLS*K$(ty|^-wQ0I;L$(PZ1gL^O4^#zHam6;Z> zN7!y!Ik*efLK%z}D+NBTuYj&#c*=Jmyn~OIG1=gIl_zDkZdu@YD_YMES@9=w9)QV? zE8IQ1RqvqZExwS*(Dmjor7k()!47qpLO&((UB6c;(f71Y5(Xe?uX<0dr|HiC#%5rK zCxGBeSg2+^)!iJMlg2xSI-5m^d64?qvlZ@`NsVvgR%WHg$2rrJiW7!E>YXO&?*=H~ z+Nx$!H$(Mei3iU$xXDapA+fhOKWEIKjubXdI}K}LJgv`oKrM<}OZWnuyUMnTlc#K} z!lN-z7E)2s``()AnX0a}@?~c})bPt@NZz5 zuI%~iF`Tt}$yR{Wj#If5B)#vbyp?+i?jrgQ#j+L{gKNJN-XvLR$Pp1v;5+xtkw*s) z!KAFx-o71#sHn=X)|G>_H&M;ow!_PJ#s}P$U|k*7WowCFU2S=Vo+-PA9MxleGV5aK zW zU37JEdPT44QrVc+DTnlOrlg_E^Qb*y-ah*Bb=S3GPBCDhKQItk#agA9nBcI{joj#e zP@B-CWY)1z8lg;+eKGNR8q;l9kcg>>ZKW$9Q(*$( zQoGK6O~n@2)o(YzH5BNcBTY4hN2s+sMZ90fZG`q`1;Bk?OXKBlsqFM=JAP$g=!g>9uT5TeA82=`)@TfOS9F2)mF4aJ`9yYz zx6_Sx6g+HXmwSZH3CXcUGLB?k4b`9q8qerAp6dv&Lc0$NtfXScfX6FQ@AZIBq5?vF zAE$U&2EyxeS9|~}4IYXTnfVRjkJqUV>@VR(zaJJH!wu-5uFS&2d*%)uf$su)(UuBe7Y7_BOZszGr_ey0mz;H2F>>?@K zam&m168VPVgO*l`w%dQzq{7r*hVPg~Wv_S-b}Ae=&2|Rc5bt`uUFD;2A(<7&_khd( z=vNyvU4B=(eD~kSZ~Z{&issvgdkc>Kq{@0{1C4H59P_n&km2=ryQ@J?iD8nmuI=v7 zQ;wc8J7%iHcHxbi7g#6MRqd2v>$^pKam84+A9cCcxqSwHErYw-+0TR+HA16{Z3^cK zBp2--)M8Gf*j&xnTExB9?gOLuW%j(G1tw)Urt8}=MqE;$nL~3 z?3J#4rq0;uoa{g*1#SBX&oskoB}r~0F@rNE08NN6espn~^;$ z!RJWr-cfje(K`rTR(eME?c-wUxP$xfa{?!fuzUZPRRmE&qqw`j@fiRNjN?C&PyFj9 zl?zoZHxw1r57~IrF+|V&P>4!@QE?{=APhu0mW@<-t_6ierMOC_wCwu4w3)qWJcM6= zAwtPj*Pdp;KMKQh_7UWt(nu5MBw$bS5uBzbc>g?J)YIJ!>vg}w@4!fd9QwDW7SG!* z54tR&^1BX?%KFNnt%%s_>)YL|s#@zr(=$(bEdfydCsO6|h$f-3&{FOYAVJ2SXPVY+`@6!ni?qHm8Q_XhWk7@$p@yOg#2;_=lzy&S^%SPfeUE-`-bfBpJFf0c>9)Kkp!-T*qU_LET&93 z$RBu{a)waQ)!4($>wZxE7@eW z9OS_oZ6RTj^#RG58=@LRBojagSExciDAzC0iwfcLMq{1O*f^MwIT;5oG;a22a&GI}=V3h|;2{|VuF_bP9ExaCESgcko z;kO%PTB!^afEkzBAzbq&u*!8L=QdgsrZzxAavJxuG=dw!WobdDxt>bNT}}iqSSYU( zqdWisn7BkC#6d3b^#Vfa>%2Z>J!O$Y7C{CgG*8^3)hkcTvgs*jJuVe%E<+7rqguR% z95diposG3alIow z+Uf@5Mb`*2HanuaQcNaE*+zKWJcU-y@+^qeks;qAVXZJsN6n|;v#{IP@vSe^E;zEZ zlq1Qpn~;?{OOw#0ail_LzwVp05SyBd*ojI)^+@op`rv%jYo2fQ!=@H8x}K%ATI+n? zKzQ;XZg;6Y7FMTIvcVdyhu~_f#WHXq3pNuY-i!W()q*n#JDIHMrOY0}BCCmdeg1tS zw>xl2W7hJLD_E4NE5cDnprl&vgA%M^{+cYE78acCG}jyShFyYelJ2U3+*x{WbYH+2 z!w~xlLFRiaHS~+6%PEr0UU3Pjvs1z{?UNKOL^E9Fe%_o@giP@wCo~qNN7{3zvY0*N z6_ZCUR=dDQA9^=T+&zyf;{$CKku+auwixIadYO9?1DpVeFvZ)O?5rtue13C^F^0|; z{Y@|EH_xsJl^H++F%P2{N%0Lh?sz!BjfSX0Mcsbz0=w_s%6gs3wTEQjjRr7Bl`KIKcsEuAH?ohRnwR8PD|vD@){cr_n34x7 zi7^YAuq$o1Z)kVBKV2_viEQ>72LPHMWd{=4Our2klYJ$kAWC1@gG9h-*8HQ14Z_sz zs3u*1^QbU+lkqb4-~mR-m1;Pd!O^U)*@CYvV#`HB4lNoJ=b9U9F+{p6N=(mV$UPQ1 zcP0tWCF9Jyw?`6!Y^3NdL=|O#;doHk;PlIR+JX#1CeG4Pvg)s9i9w8|bNnElht4Mu zcg!+eKW*Z#1!xRb26n()vcix&;_Ut`H0B|oH|iNTJ9CainNhhK=mnjDIVQSzFmp)J zNM$Qs4h=qmnSO?SJHVcfi8O+{eJ03anYj|6iNqYWw=p%7W;d(G9LI-;pr3)FvXYJc z$(A@|!T5W($fZcixGRu+nGP0uhtg^vvGsZ){I(s2?Z7 zD31(@MQ%;qe)kKfC#$lc50&P9QI4fGqN4Q}Q!;D!(C0A`gw{p}DFAKtisP0vZJ7=P zd7v4G1<)>K?%kfvFWU$B`pz^=po$rB6G=n^u-LE zufiy7XL@UkjW{8uJj9)C)2PsD>nkZYei=eq_ zJdQ*K1MMPACa_Z=Pk^zXitNPOTjO-m#t*KS%Zw|Y!D1DRnj~uRmUx=dwpK`$Sq(TO zN_8;AN?8tdt*S=m9s8lt3HbVAx!rMhm~vFuJACaxmfc5oLYuI)A@yWzAb3c@lct4d z{Lr>Oxmo-DJx3PPd3?Gw-0PA8NGkZ}r*9GQy z*(jP3@1|cg$|p@q>qu+ov?UZz?A%SijCnO`?01s{GpLvE+am@}vSd9z!q-ootPOhi za27>Z1;*NW8nZ3sa)-|m+Qe5$QrwMKFC9i4eM;%qE&%ARblOSVSw=xS@m>iQ%W+NZ z3x?$uHw0;w2J@$JH0>s<3YX^q@a}ab6z7!XR2>ey8ErIXHIoq$CX)(f7dmd6-1Q{* zIaAnWbW64QcF~bG8{;*C<+f&8Zli=uh}*s>`B<6`)#sn;-H0gVXQLG#bM4aeLm#lY z^Mpo+E7FQA>}jK^Om{SKW#F?+w#uCP0KWJp!cLn%(A7#F_iwB*RwG3b{^IntoI zO?RTtmK0vxPoJr1Vh%|;HNe*?#x>;22V1*vV%q&d$LF^5Osdo3D4eg4YBW8D9httlViRnmxm_)IkF zL%ba=iPIwx`ZgOWHe;A)UKCd;EHq0ghP~=B-9Wl4cTd}_lwxg~Gib&FZ(=}JLZZJ+ z6Jlfc#Ihwa?6JMIev&?1s}Y3ZAMS@nS%cQTI|*L#qlbsx0=z<~8lr|4wubh*{vS&) zx!h^+=L1c!KQcds?ts~YFB{^jJ#q|Ic?qL({PwAhhu~13?Oxt^Ey=h?Ik-6@sG0{}e)7%NL!EE03=uOD zUSAbJOIqYoI;8e(_}j2c0@y*g&LL}2C4?2fMP@Pw z(|6xEE~Z7^1~q%6?p05y-l%F1-BpP`IP;pW&vS(D2t9K;BCL+FeVT7d(L<^o(CJjU zj&4?Q>!0`h!Czk%xt`I5B9v)1d)+W?f2lU;nxu-UM4Xo=m&1Hp)q9`rE`!mXZqor@ z|5K7+KS{8A6j=1z<;Z$EFO|RDKB=jn#qpezX8*3FP_Yz_?iV&)+GR5e&zkw0RRe?Z z+({3@wGfO1O~5mfuk;8sn{IRRb3vZ)g;r_Hy$>Y`@XHtc@wS@_?ZL7vPrf9y~@NrYM-MWcEY)Gd;{%+4GToGu|GO7JH7qow|`31 zp5&h-Ng%<%oZ-R1B>sOVYI@Ed_9n&xj%NQ4>tFvJfg4pVwEnRN)D9U+dLFr^5VbK1 zCoRv*k49mCV)s{*M!uW%sN=3KDdpPM!kHd1{j3%Id878Y1Hqn!KJ>-083! zN;)-F)g!G)M1Hd{;YThqSacTgg(sT!KiGF5j9SvDPm;^b6(rd1;|tB}Fm-fkMA0VW z;bfHaPvct&ty&AzCyoYk$85VyW_ua)(H45uNtu+t+AA-MlZScBRq!#fFmoqrqpJ;; zP{#KrteR-lnMdbd8ARUn_1iz^z}@D>|;}O1~#}4Z#Sy>rb zv%jwp^yKuR$i|9@VK_%v+*t@AkA@EY39ytOtf+`LfX2@rbg25^0HKS$Ct4>fM^JH$ z)=X|@;*Y_n0|(Q+#*&_%w(P0Q#W z2%4k~^lOjOEg%oRqDs||RVHO7jPORVwm?;(9gjfCQ;3D5)6EN~iS(KsCgUWj zijF2h%|`7O>U2v<3!6G@#Z5g4Uik6*i637-7(;_HT}6Qg$$$Sdya$WtQrdj^$#pU^ zdvJW?jr-w{hS(#Qo|#1PbVA&cS(fIR7t)8E-)4w0oVVfb)lr~0yP2^^FR(+>m{$z< z=JxY#c2<+4qcE&J+_l(Q$t(}YRi~coo>PLOj;^Wo2t`|lw)b#bdmkJws|vP4%6v`x zuD+%?5ZB4&B|UX}bN3C)`rgYt;!i*i17ui9YRT!^L!K_-*X?)+33T>M$)%{m(v z2L9Dq{sNC)BcYVVt z5BZ`UVzcKvu|sEXySnUK?7k8;&tN=m^o8LgxfxY`Vne-dhXnf@Ss2BX$}uGRWgm-K z|F(bXS+V>5gPow}HhR<)r7oExD_%;Is8KYt{J1@Hrjz$|h}R!mBMX$OtK7|@F;{Ir zwB;Bo_$vG%^bL_usO}nETSNFN{ubjS8})UhV9Gps5mYD!YqQ_S_vKLq2Y5X z5S={Cf8z2Nqv$SbQS@fr0}J5CwuDG!xOwI9rQE^g*7gl>YKOtH^JWX~{nqdT-G_)T zW71DV$^G#v;vdWJ>=QD3SbTRlqgvv6u-)f*Z_&51#R9|Jq$|?gmj}GQ-Ld_%7imZh z<0gTFfl2@MBl-UlFZy@27;-2As66xNl!f8~BJ%vPXj1;P4_p-S`d>9G8NT$vA{e)E z^);-ow&6#0+$7K=$+*+={>hJYYolN&twp#$+)Cr}y5e}6$U3jy#^3|v9)<-qC$H1n zG*9u|DUnxtPO_CNt}5cDRTEy}uXQ;AGns=sPjNe80hc-CR$R)eKXgn`0i~L@I>2CJ zV4#NES24Fi04C&eVSF!S|FHGpn0I)R zEsS42ddedv?JHPASF_T_NpMq_r^_#?U})I{d_Z)eT?Iqp>n~NTWqRfypdtWvb!)AV z;ioeUbR<17GZILWPsxBLW%kiY8)%y`A#rrk;J;E8GX!&Sdb0US%7q#{exAptnyhrxaZ5}f zt(VAvmRAF9yLjv_^=P(+1!3;!f$^eZ5p3_Cm;fVO#Ve5>8$A2)L99x43XFNuD!8zb zGt^J&hs#KVy1;m{MDOn;dStdj?YH7LKkmhYg-08MyEvEkrg)!-t2qtjutClm6uU_{ zmwy5vot>pLqJJyN5IUaEg%-GO&eRVyE$GQz1Q9C>olEH{z7{Fk&Y(|<`B!n~rkVaa z{xV{G??o^LvH1-zzA((Z+Cr9I`~&GiH-7m7aGX3nTH!tXKzE^vpO6(1O&=b4nWL&8 z(TUa1UMM=4(9rIm;hZOBtDqYB7D>yIHtsWc|2%QZQDng~;mGH3g^ib~6Cf>&uNN?( zA7@C6ojoHPIMKYXbaB<-C&`*y{AGIW+Uh<8%#?ymYDVgNXi6NFQEMbxMZ8!S;O<{e zp#`_}_?debCS|4`l2J)dalltW1^Ili37IJQm`8wg<94t~c!I(J$%ySh5J%c(!U z^80v3>G_HZG*zxAKAy}o9OX-9X>73?=a?L^5*6@OPI^?`T58Tz^Wx?+LQ|Aguhqjm z^Inz5on-F20R#5ml`f9nS1BqO>*Q9Bx9U`JhMm?co!-4qW;SR!W$T;!t&4*Bf@XiO zLjMZOwUA_DHcK}k+557e77OdT_w6Xs2pVvjcY2aeaHT4F>pEFQv&<7omS+Xv`z$6c z7Oh(y5Z94lp{YMNhipF7nExfKfuvc`ke(_F@EF((UO;noW%l}wssjkb*a7UJ2hf=J z*noy=aueBwjy0|Y8gg|?T;!$uIX0Ce0J-CNsKOX+gH=yJ@3<39#L1JSnNVKHZvHWP zF9BC=25brg%|3LsR@Ghf-ucLIjRGU?Fl$fZ{lPD?!qc_}E8gFwl(KO=G_LqkvcLoZ zxDc1dr`N`rQ^)oZd8}Aro&z$KRwKm^VIeUNYJFl*Of}X_mkb*m0$HZ9$+;(vbBNaK z>5|z*03P(^CYEfyt^H)LB9TGLaKtC!crzv88SMVpAaW398{E`geVgHWd0*(+C4!;y zHGZ)4z;>$J4gBD!{6QUaN1_uO!<7_p*sPdn-+<;PH0Zgp_xp8hu9}K7k5bc9OPt@O3@(Z~-H!tntc91~LRkz@c^w&^x3 zQpqmj{w6iJmdigbjc?Kt>ip@ugC@a2XCsSaq06u$0@Zf92hs6rIKf9)l4$tvyv(6P zWLPsof;TqqrD3yHp!H&Sv;j^{;OiL@un;P8iHKg)8E9xhhsR{Ps`z4*O@(o!%bA%? z$cBJL6|PHq={HI}RUv+inWR`HLXix;=yf+IPmagw)*n@xU}!<%%f^f2$Ic)T$Pp%~ zAW8)cS89T)_FI~0e^@Ap=q^%EkDvEYWd_(-SFez^~ zKtmwa8XaL4Sh^pv=D{FUsoQFgoXHmHLKkaPG ztQD}!&-B3p3H?f*cwh8w<=idoz4~IRkHiGO^Y&z=r{s(n5skW^!)f{rlSy63WaZ!) z2Fbiy6ge4}Z2N`Q5=Gk0)P&&f+;v~;KO^dOxkkP7U+@G-LypX_a}vz6f$*@N_IO{- zZGRpO(eL)b^&X!s?G7H@sYARF_jItprZ1>@rgS1GdJ7A_?`j(?V@@9LcW0g;~i41KF3=5 z&|T)JJMym~+DX+!)} z^SzC2!0Pj*-apG{Rk-a>vcK7b>wgi=GX8I9mL(Zca4xT@QJ6Y-W(e|sq1gf^l(v@L zu7wM==U%xJBdrGBF|&6W+GAN#3MtEyCGIq@>5W64Oq^BU=CR7<#qj9Ri)I5#4+%QXO9agEjWT~)0YgwF%sv{|MTxup&5!_HS z#y}S^G3ThDx{>99KTJx2LV=|w4kD4IgqYlEI>{svLqI|V!$=G~Q2(cOEXE{Y(m_P2 zg20ewq31(%2;p&x{%s2?SbGBScoiMep{nCE9(*-O)+0gG4IW@Sd@{FM*nX(Yc9Sk^ur(M zSR61cL0GK~qo-RSRz~c-a71%7ZiMi8)+^DHWV@tde;7#hl5PK)Z0u4&?rxE#`V?AQrJ|63 zau{8EWk@C+Uu{cb1Op;-SOoI#WM4J8wrie^fQH$nfkpwuj}?d-$6fi$1m_Kzm^f-P z!_@5#N+sTi#FxU7wC*u8f)~-tph|+IWf_gn6jn+~n5XUszlTJHgkTX4m*34hr>vwb z?!GcBJM~AXk$+*p9|-i$BVB#ij^;qEVfA1CJ3wp+>rIwI|gZKaF~fcC0YG|#94oK?1l+A|bw znt$-D`XRW>W)_lA!MWdiBR7PAoEx`okP2%RzPm(NKQG&`8I0-zZ=QANC zle%j1Y4Re$^$-@BW@{~(%wNyugmWD`#X`&f;Mu{Fr@I)m^uIjoMxLK$JjVE!XX6Og zdY_*AeEz|+sI7sISLamEN|s-fIGlHoeChg+^&Kqu;1NY3RQS0NbkcWI`4VXG&dmDx zelT`L`$Hqk`dSfXiX09zz|T1q+2J~*Z(%@(5d8*r1&gmZ!IJq2_eHgS*!{S#z9I3k za&z#lBJ%iNs^`JJ$7H#_`|?$Qj`Y;eIT34z6wWRF1N{Iok4!)=JlnzZ(t}y+&OF() zkCi9^D*k|QMv6s_5uPiniOkmkmkCYF=5EP)yH#|mv_>M3|4n(Og+)atE4s>6mlWqi zX#n-O%nKLRZ8yDs_liIBr1&oM-i}{$-4$z-rNed(*pzm@<%s1};2#dk-_6_)@z^cMm!_nr8lxd0hFz_|u+8Dsq_O~CMSg&a!f?*NP1(72fu0$_Q zpH>F`U_%d zI+A@{z8c2|%if3R*IF-6i&i`AdZCDSZA4BrDa9#`o>Zg3$KmkzLd{+Zu0O=@hK0k+ zr1TUu33e%F*Nh8SIW?qE}xW%Z6s_{>+3vvO%H~ zgTT#wgaW;sWv@V6I-`Y^n6O;&t$v~133S6MnJs^7ljxn)0(U-YG}u2vhK4LMjr4lP zozeb%BcuZ0(@_OH+V_tHZ!Gawf(4n z!)5Dc*7A{dZE^`RFn0cIuuNJgM~YOmEO(E0ZMM&lbsn`D3y;g0BN=vTtxm&@c%VD`#&39DWRyL`qDx}E|-L&ifHLb zl@ahS&8RZuHihaFeW3_5mrSx{O{p<r$uZ4w+U*mjAxM9Q6t+SCLj=5s_)1x7}PU zv#Ru*X7g|vE1mR!-t?i3TrHyKo<|S60RsE?M+>bDgzQe!3+I=4miS1d~(4)~Y zx7K}HY@3TZ5{1`zaGp41hbT!h>~DoPnP5)cfoa zzOBEBK;n+>9MO20N;6S+00ro515f;xkOBsV3-`WVNH(nv0{0^GRTsy7T}P6j!9T;? zY#nZ}{bOF{=>Qq6)W?uK3yrc|SOYK1V5R8?6&1$v*~?-~a8pnzZby zwRqq+X}K2-klu9=J4sH)U@We(XkqXkLu6ZORnV*Hgs2;|r@`Di!{Z&%CcRDzAka2x z@kJezuxB8~oLXp6BU~o8Ok@Waa~3BH5rQ&sCT`fg6|O-QMFkio`X_nDngZ=xT1{`y z0%b0r!%~;C+71T!8SyWNU5pFx_W|z#cV`WS?U(V&hR=WYp9SMxq4bco_);svqycOa z3b()Noqj01&pS)?eyeLo!Of!qNq{EedWZQ*o-%W_RBKOd9Tj|xJ%1PKr|qgR7F^>J zc>Tel45`*&UOGBR{%ZB;g>%N<)teYl?GPdvcexPiWhQh@pD7+ImVs`kruOhdN%*(x zy9Du)H5r|FvSb{VFZ`ilMmEC%v{>Nl0`VPVs}nhSON1<#mII z%iZUftN;;e25ycM!Gb^6_R9w98il{ySf`!js?V}zophp9gYcwuZZYEcc|e>d>h7`awCi_f-2xb5?|&4DHBk~h_s&q-)O6ut(c~{Ws??<1>B^X(-Z&b zh2JSfjI}3HI(3PXs3>!FYM2O5mRxOI_m(OtLXa>eVopkLjF=`aL<&Bc%~n8fT5$%k zaoVW8i~W@O?x*1<-D!9e|HhC>E3b*39tGnTkmjxnb@|MaFptT}Lj)Lig@zz8+^k|G zzqp=AS8U^>Rs{q~$&5J#(q&XhXciyHoE&E|>jIW;^BT?2Fg>-Ll{Ae)?;pof0Q@vO zDsRbn_zkAw@V|nf9=v|&5vinW6tGI#_*ov1$QFv~<Cig;U zC%)xw4I2l(@K_X%UrBJq^+X3SJ%399r{YT(w6&}+-F7J3a5KSP)-`%Yh@`p4`#s-u z!!a&uQEF#xOy3rTT}`i0v5s6K)dWGxypNSZ#I;4x6UABL=kOyNHz(nbc>)=Dl;_9nK* zY8KAs%K!bz|4dXaoKQr7pEmKN%rbgN0i%SjrLGr*e~OBB+^O)_kq zR?C~+FyDv7K5??*;$|^JroE519lTwgdbpr?}IVqEOejjhxJvhGl>JIP8 zI!_t0`kz4P=GOf4^jH@H?7v^VOw8qw2gYyf@L}**EwX0SzQ^ylwZpm(`Ji>|+JW@Z zpA5>!Y_=+{nN3Ds@Eomr>$|q<+FSw6@)obTWCa(m{`kRyjwV+PT1~M82nGAABznkf zj)J@6j#kT5j4Nc!qdx{?E7aA1qVPFAp{9T=miw~YWk9Fx$uMXN)3bjj13*zn7&BV; zI6h`7Vhb~@a{^y25~YI2=|bjA&be*~yeMkrx1e>N*ERd`FDs&O0P9V&4 zPZFF_PaD|#f3g;gH+%5ciYGe;Knj5PF+y{Tk6vsO*w=cHZ9u#4tf1ltu5xQ@3s#>^ zr!*bGncXS%zBR_qnr3L=CGvJ{5dut++?tFs;*ufz6AXuh4hZvXE8uiXaHBK0#S6AN zRLs>TtM8kTr>-~>Eq$aMjzUXkmU8BXvHY;bq@3E;4m}zMxh1@s15E^;Ja$o^jR2aC zPrg7&1NzJQLrd3}{=$96-WfXMb-UXY{QkqR{y-Lwg$fNcKJyhW?h1(L*fH0Fd!c=m z)hEJfCrdoC#+&8Rm)s@dO6MMxgJ2GCkTe~#dI3Bc!p3-R(0sZR*~^Ob*x>RudEhjc zk8@q7DZkO@@>Y3u$d^;J81k6)ECw&5?E$p%kokp4zn*69wRNhs0}{1vQmr^=u2uDD zwfaG$KTr0*aA4F&_h6OH&njhEPi0O#*X|@qG))cTnK|!$iG3O65P7^yy?aztHvc-c5rCPw^arPH@of|n>MlCixk7B?! z(u-^b{xQM{b)X;01%?>m^jZYkS>Oa*D%8_q`Udb~S@l($tNd8VboM<%n0PnUbWJ!} zg~wpCpI<3*fE9`7VaBUcs_^g*D7dK=R;=Vn&hZRbe|fu7$I|e9`z9s*d6=_%sZ{ad z+4Ha>wQSTP_TBh-EyzIq#~Y{%pn{V#m-l@;y@K8dDl$)!xiEu_R}Og_lRM-JI%ob2 zU~*SMIAosWOXz@Shu!hht?Y!(==}3-pYW8j08N(Sp3)fY71dFa={qVU8VD7HCW>14 zs*~>}vC9+ugf@gm9gKiVOXXccpG#jFtCp(x3^gBqYx~GTbu?4Bxmm9cBV ze;KM)cWCHeDp16MruX?@!N7$7s*lQlT7CXWcAWoj{rMjXw5Y7*G%t$!u_U`E%+eb7 z4SY~0;)@nE(isG~>^v!fg-QnTPNc`kSEBLo`q~c5!laOoWZs!^1aq$W1^~kQmv;r+ zDN0rv^`eAaI^3A)Y@QUmX_@2c?(Fw3JLr@EuIO!6EZ=eX$ZDd?hG@b3DUra4qFP@^ zPz+Y?leq6)(z-fqx=EUw^|aT>;jXS);??P`>DI(g2K6MOb|dU;8FH=%qUE?k$?+6C zj3I?$8yw-jxDAZl+Qq|(B;T0M&N<64gIg65NFhM$a=!_EAAMaPL`pdT0XFG_8duMG zgTKu`_jcuEvu|!^0pZlUof)Wbo;xWR)I1a`m`sp$9BOP?T;9*WO88+B|DQ&pqmm^<5M z_L~|8A)E|;_4{=oGI!>srA(6CJx+2tuP&HBSwF+6U9jbFTejhk2x>L-1-Iii z{?gEb1Wa_G;^rg6Nuz@0UvPd|6G*cTSLoCdh#N}kmT;OITusL`wmdj2_-^qtUtLAM| zE`^+@P9lcc^XYJnB)fehHQwDaKS@5oSVPaqSW|}~9#jyEYcwV(?v*Df-k6K*k?azL zbMa$y<_^(|w>`qCUaQ&bOQ7D1vjuDGyL;a_qR(h&VS%~ZD1x8S*WJT7zCTt`wEJQl zaGc1@>Gnou?E>~zB9UknwzLK;h$RMJJ-lU|p-3uRZRVZ{dHI4xjz4V}Z%i_o`~M`4 zeE%b^^*qxjhHkrup4DQrYm_)@nrY3O?`MloJ+S8VFUK)df&UZ+2Lns{dr$a3r3(MP znO32ajvcZBB9B|{3^=-GBYLAcO_R5}sy72tz|s(DL5YNv`>BxAs?Iv>xXxxL;=3Bl zEYRor`*VJzQ?op_)j$CP7ZaD)(FT{9so&e%BW{n8GA33s8@)~Sw9nn9y!yMYzH4wp zFgIgP&g;$${D#oOZl#gkiKrztrN33pocQMm&>h^!J1-(mhx`Mz!4iCV?#f#(p$ z;!*UgQvkw5mVZl!|J$eRYXSR%pG)hoLpptdbmkOjB|qZD0^#s$cjmw%9H zQ{m`H9vZ&YS+0Ktl+Ujyi4(GX|EdNg3^pVS)+j(p6#x;GMT_)YSGaIwNtC3<5>;Q( zS76Yd>2#zTrqaj@iCB^cTeV~hU~jGcIoqq>G7jH_bYD-{56f-vu?RFlHzU25Zfjh#xz1 z?Krpu^Xt8YN(XvQL;2-jk`uG{a%)QFh9+Xg?-5KGJ%h<8t@UZajWs}J7LOTR_ z6)63NyiZ}#c|o_yqqJGRy@XYklN6lOi9G??@2SV9sbm_jK`*yz)Y$CM1_&cVB~a=d zV(HvOGK3KsW(88t7c$}K6kYhf(;)W?D&zdbO`s7Gy^vmy&@IPai%e_MSK;Kh?6 zl?{>@FPVyW@P8hq1#AjZ^nYVm7%(uw|HD!G?^|b#NJ&y8t|^AbW9ZAwHpPc3!lWaE z%NSZlz}K&8_mO0@ySOEk=DcIR5}ie=i<;r^{mIYFZYR!K3NkGAy%xG$^}1?3=04`2 zd-;8}{Q+hVyUl1U-JYv<(YAZC%@VA^=lBpFt3qJ)nx%AlsitGK{pW+Bx{9NbhR$;) zv53qXQzp0v7@pQAc{yT^*=90Ro;8NV)_HIFq;P4imSK}%lVSmy_2WgzjUo7`mI*?J|%zABXGy5+g(xr-O>n}EbVOGhUROA}?2 zVNO-HpY0d8P%nMO;tXC{ruy8!?-G2wrWNv2#)6wdJQYP8f)Y(y10tbRsyAtvrE<_M z(9`N`ceafnMh=b}0QTZRzS4~M6tRHvYKij~7U5RBaL$c`yg^(+QseO|rEhn&otMbr zZum%m{?gXKmLdCxLj9oo79u#QX-TwhDrvT$-vEqBA={<9rW8BPrK(*-ooq*L{ND<- zI>eOVM~I8`5l@&!Q__-9cK{=n%Ovt67cE8WgWwU(@IkK`6IbSYwTG||c4RC=amJdt zALy8*IJEHJsxP^ZqI20dSTozg^$C+N;M9jhVC=&Uv>kPIngPo+n_>fXgdjI{%WcLt z;P2TyyVw-G##QLG-S6lZ4>S)4ZwhlZO2JMsG&|U5TCT8h34MCO_h0UXE z1s3kWDex%O*dsrF8iB^2;SnH#oiO?KVn85nzJaw-s6n03EplAP0)a>AB?GZzXL^-6 z8*ak(VUnrMe%Ns7yR}62A1%;g!O=%)OJ`GgmWlHEEsbT^p7gPmlGIdz>6G_1t4cYE zqoyToV{Ds`Q!pzw7FYblIX?j zZW^m+Z6e`E&U_?HK|3cUuWDf8b1P3IaE@(MWfS|f@8py637x`Z?Zkh?qsiijfwI&% zMDgm}L&AHQMZS-)2UMCU#Q-uu&A*&2krpZ}_b$QS^q*25(9e2fay1uX{lKk$l{p1n z-ygvQ z0gdspx9?dX5tn_;LP&FT=Y`vo^5vwwrldP#b6WXOFEE?g%X>g3MQQf8>~yVil5eP=$hSQXVB!{;hJP_HLSMOmhT6Ou0W6_7RCu7;K zL0yOEFi}&N2!+UBi)p|G4%Zbu!$bP=B(a%<2xs{cM^Di5T7IDo(0H1A&v2;}Optm6 zdgrOXM!xJ}PXX<&oju?Jwx9WR;#nxBlqFOM=2o9 zaQ~BQGKxf?@Y?k?25!IBB9y+ zTUSWBEw6vyamp(}*;3H|lzpFmb+g^;G1DyP?p)*J?RPUD*oJ>^EK7xFnc<={*MpA` zu+fK11-*{Yn8l@ek&6p$c*DVueJ2T`z3p7o6?@J;n1rYqTpuuaKBR4+XLm-SRIFol z=wsWG_HtR;H=65clX4M@35xTxL--X#;A)ET1waol4o1D?4n*RCEIwJI9HQE3AZ+46 zgZur{42ET#C0P1`?RP2sm#K(fr_$C5xgUCBqf?2JX~GsnOjm=yW5heBFaX3UkZyZKXqw+HrP^x5NxfS{$0bQ z#pQxUq-`U)C3RH1KaUcE*4GK28pUI?s6y-7te zxs^+Zt;?4*Z{K4qjXwy{rlz6gIG`;@3k}aM8j)$3`k%oi`TFM15k?uZYoEqHsPXpz zo^2-4>4?tnKN5q=RT9EvE$Y?JYavbMC?)|vQ^K-0`V6xuFo;HmdvMB zZQsri9?FMnapKy_=w1ono}PmAZ*v1gkG{Q(+cH;}%Jjj7LngA7$S9M?WT+BZQi*g< zm5n1*fUF=M9UG-og3he-XwoyUPw6cyIhObKwuZWz>_a8ZXKsr-gEppd^Kd&(ulFFn5NWx0Ky}0mj(X9m^cKKl?T~bd0Uk5xurk^s zed8)!Pe;PW^CeAD8e1nJ#GC`dFQLm+8LJ7v#F1Y$;xRYKP(K$}Q}!Ue2Yug>cV4cF zG2cbfr(3J3rT8(J`%_FxPIgU|xlzWO@X7FQ zkSJt-AryMZ6!9;CZ%DWw5yx5T5_NjHm{(}frOfSADkYc-^r9oca zhs0>ygcSvraPJeO2>XPdUoH0t9e$Gbig7kL`tLN5cvD|A?HzxOR)f$tL^juO5Beq7 zjkmYt85ZjFhWB-50rYcEy(E-E^!u~gO>9KdUgvlAkh9nI-v$Zk`mYpi!fEujqyz zV$a9_wenwvJ`(Ll{_%r`{>Klt|1YiWzy2W_2wsXS&fmJ;Jg@34!B&B}M8Erf(oBpp z4kD+^h1G`!_Ak2pMigIbe2fY<9y=9>0%XL+nuxbHTdo%o6>X)ELN?Ub(Lw|&8i=i1 zZLK$Ltea@A6sc#HNUv0DXWFQvdR%)a&`jK`iup|V^sb$5Io4kDylg()@tyEKB)d*$ z|EdeAHoX012;L)Cr5pYJObYD(>h7o|$3qA%^z>T6sqWz_5_KU*M6}3{zT?qEMa2qq ztmQ0v`$Hvdd9zjtX}&nAcOxwl7;Z;1Ugs^mAv8^ReC6z6xUqUZEtK_aW_1xu`e(1q z_GZ?PeoE-2xroBo@Ch-bbJ-bk-QmUha+Im%i)YJA+gerCP+C>=z=J5nTDyS0s&E05a`QbLO3Xit(q|3dq3>65G1T)Q(5b&_L2cZnuW_4S}EKt16ByZAo zGkTR7s&Q}tqu1b=demoTxw%XyM#zusE89o3g|Gi`(~UeAhfEwr$s7nh9SZe(xkySH zmNAivm@=@IbxX#5ULL*9c~Z(EUh5$GEJ>=7GqdBuop1r0a1M3abcR_#$bqh`U8Gh{ zeG#O=Z2;-R{zkORPgf*xlb$z!`&cCC`#4kUV^-VvxrhtAvVIGje%6q7eiqac7SLqim7aA;e{lBXo4#LLE*C5Hyf9-*_}n}I>Mr$q_wc+^P<rGMMzLIj*jl_`}-2X+oImlr9?`kDV|Jq0B z42C6R7MozQMKfYTg?0`T&RV4~KddCtz~FPN-;0Q2OaLf_R&r8r_ap?6MkmkS3tB$y z6Rp!6o&rsw;wpt0yW>@P>?7*}oaTbbgx=Ddn#_5T7opmhUlB@Duc_Xj&!_I(qNwgF z%%9g#;6lw(d4r5E`*lJ{DMQ!);5Y>EYBUH&lQd7#q5i=Glz|9-9K9?tHF7xKtn9C+ z4gGSgfuY>fAfFT1goJ9i{eY(sal?X9-NK5k{N?NeBH1GF0NV5Pr^^}^EXIMq+y5Iz zMr3T7Azv?~T%u;Hl* zi3}xvh@X1qwDgK_(I?M6`SO8BG<4YSke%b>c>gQ0OYD?k6_)vxSfQDSPrIWrmWu8x zG4g)IO=+0e%?iglmaj6%FsV^thtX+rz0`4lAs08R?|?DpK=w36c7>$5g0+CuVh6!5 zC=_~T0A{Y^{@B+ZVZxZ-KU04o6(IM6gcu4pyw?O}G3(A;JIdYkfJNk*4UlsDQxl3q z88?j5@xjt=6OIl1mNYBTF65q@Pt8<;aIql&?gaN$Z_)HAdSUU@y^_`C>ARWp@t&S! zfp*F>eC$L)b6Y!N`aqbQCsx5ro?xyluRY8dvdzzQDztpFVBT%0sRRQXG{H#gk%K%L z_V?Epj;tN8E)h-U=lYB^O{a^(Hy+}+ypbmz8K_|vii7l!blJzZ$BX?P=38>OsiJ(+*Na8Z=s_4lU?i_b0^uB#iu0M*J8&{mwjPW52x-`Q{VyjRxmqd;6w* zv&07#&-n^I*1GKWMt%7%u!I~E{rn!ma*HHua`4~z>$R8gpXyQJyiC%nW=oQj19dLv ztviYU-^K@$N4 zG~cw>z#l;LuW1W>lMpOtvSGAaPWTWSzQKHMkY8QuL;qv&@ zVDY@tNDBL9VQ6*<>~XzP9p?k;+H@8m@Hy?g>WQ^9xqFSn5V6p=ok;E z-|1L48j*!|c?YhlmBVvT@60@CcL8m(ZujxY#9P+s3Ce2?F=IE5A69?o?V>CQ=OXHy zmHeT3zmYgP$2EQZUf9IWhZC}b9SjJxYbIDUXA+%Aj)d?M2eZxU?W*%Hsu`V z9D~C|qc;5eeSzHj#Hg~>?tI|l&REiLmm>7+)UluxsU_$-C5}1S;StYc-QZikbD_I| zd^_>AWErN@Ot!1rX;P)A+v_0(VW0FOj`)3E?9; z+kfA3_%9Z4!&q}#(}I<6*}*G@0B{O<5n2kOkdB~eg?w+V2rR!jb|ryX_|>$0DyN3D zFgsk2nFz1kzGL!r&ux=Y$bz%9zmHfhugXfZg?fyebi~9Jn6TePJX14mX;>eW6IK~G z?8%Sslcu+)hcs5+2a79J730w=fOTd9Xd%2LDe_}C$FX0CHnjy>FkLa~*DbAwHT zH8fBv_jJPT&cMf`-P8bo^vIR!Ga(;6RF(69df;eJn$l16Hc>BddF7vI@|VB;)X<=k zosCElp{p@&%yjgSaHyhmHjX+mwWypFrt3dq(4Mh+Y_y5BR!Y^>r~<wfshAz;Fs-^e5rf7K>M8C2_Y0-@jweNUjFI%a-9 z!%Nq3!wT1<+9QT<(udw@Zs`us`&rpgC>ag$1_*9NL@S!wZBW<{4%;4JRdK2J<+JC6 zYS!lt-TdV?x_o-)xTJ|Vpy4$X$P=T)85R`PQ;mq3Q_55r)~2fR)M!uo7F-nHta6>B5))j)pBdlfaRJk~yyiRt z0ZAye&Jj;wba?#92KYn4evXOlFXFphFx`Y;5p(QCDk?p9amF&3O z?Ia@-eH#KjgT}UsC+$O$xf5d?pRpt==;H-bV&I%il2^HnKE=@e+l10|SdX}2Ju)cc zK2QP2nOWe!IU*bG4pH~U9^s>^{@U!kn-U-Tr(gBY(O43p-6v0aRnHZ*ULjBXb>x6d z@X2Cu;z8se(uUqMTn+53PZcov;c7srQ(~(_w)jDie@x9juyehu91u;ohy9UO^@5Xs zGy}t>OWNd>Y#=3&B_4J;ID2BQ&F^tKpso4ehLZg#1|CnUP2=d{HWd>se zWY7BgK&JNwAIUA|4BKuh4X_owLefH+;)77^<~DU6jd`t`Oh4Vb@!U?_yU_GkEc0r( z^iG_^yHf&f5SrWLEETP04E3#H?Oy?C6VRFRB4SGed=gV<~EB3ayNe2a1cLD;f z^^7GbFcxg}>)ldHD+6)MwU`uR=nG=ea*2?7bE$%U)x~hPVIUd8@rJVE4NbYh6XyR- z=EaFoH_!Xv%lk$QgTUb~iW*oNVztnkyh{T5c7MvuSKlq0GVWx*l9 zTy8{dovKP0wi>*E{lZC4lA)S0dPz*b^kGcgk~L{~AG=%2<~B!=e;S-3SD|j|oYO(J zh7>NJQ&qTP5t&$yFj$_bAp?GYKe-OC?EEOdVr5_@RB6$}oUFc#P-&@4rZuqnv$n6& z5=6TNS<*=5azsZntG~3ym`#g0gTZ;4o487XIe}! zVmKJ&;W{cJu5Oq2HuT+)cbtqOk*-+euuF=#93{@J_`44I?IA@32A_Uu zS%6qV)W13M!Zr_G1jdU3je@AK$&}MxGVk7k8lFZlm_(aS4hM%tJMn{-jn+<+AQNFT z1S7$t_+J@SOH(cax?yvSKottbZOrQ7hI{1apC_`Bxgt_yScueli9D;HNdD2vs+k?H z=afHvHO(R|3M;BjaTA&Tkjb?wLEW@KF$!`MG3k4drHAu#x)1MnWDPk32~-n&1g6w~ z<`#-5W;GoBz?%&g$iQw849s_04l;4?87tz5YsxS!O0)|4b_3L$Cjl=U5Njve8?VXm zWO57@FmwBkD``ub>A&_IPE1tp3V^DJz@py$}Cns|t_oUi`0XQew7fJ0Vq+ zj_z9gJMCj-`mB*Dip*8!{}Br?zFlLerJ!~1NSG$%pxfpCYj+)##=mjWkmgFaKl4w1 zMNHve3)1G?4lx1mw|Z>FY9r?A*y+n&lb`Ghsp|4}{LvyNXKrQ+VPq~t^E<^$m$& zmjr4zxOc+oPygGe%<{c#FxN@oS3Bz8a}5&%)nOSj8dYE?G?FrhF~Ooe7Fqm+6iI^- z)c$=RVj$hU<8q(gvuD`G{Ig(+Lc)phL0J|7psekFlZ647nSSZ8gDkqDVjSEy^Fcrd zwlLxBuRlv9Q4N%du{^6h#Pt}O^J~!&T1tN!BGL$E%A|Vh)KLz5|$g=cHse0S1Nnk?{Ma>Tp3%kq>LDW`F|S$PqUY(Pai*c)dCbj$UOL_lrNi7 zo09F#tOx6Tq4hc^`=Ss|shXlyIvZRDyN0XE5r|${3SkiHB~ZMSja_iL8gCl;Kbj__ z5!7Jid5P;hlzgTHbPx-T$gI^k6S1svhYC3f`VDbZpsR8pRA|zhLMDw`O+8d5VA8Xx z(sI$m2B(Y|Pm2RG8&a%c5!C`IS72?%bG}M;?bmU#Ff^iOw0{b2-9v;rG~dB-<_s)= zbqhH%DS0BW*LK5{sThwptI0=!xEIdOS}tbvRVw;}*QcyFb|=AWWn6oYjI~N#P*{~- zaF&D51)s5ga`U#9khN_36`(4XTiJ`qRBUOCD)SF-MxlvvADhVbFuNC~Ng{2rZw%i~ zlX9?4S1l~vpVJDK2$>4MAzJRIrBBWlygv}F&66FqZ$0inln#5*2X&1;(`Kb)eEYH3 z=E&pZ9K{cDc6erRW@ti-!_lzb;!UUS;`x~AWkX;P4!cw1M30BG)^^q+J>G*~y^wTb zLZr-uWx29FM3-^J*PNjVwM3_D4Igv?!mUz zI_gU0JuRS@QwKC+W(>;{2tQMe6%tNL%=KrpNs-=UJJJ!}UXUN?G?LJlmP- zp{)8!mHOavwb7zkXuG&nhu773RfnaW^b2=}p5@V>`#vsQK-sgBO|D@TX*=VE|xse>V6wxh5Oxaf|q7bAT zy&c3>_@g(Gp6FDK?k;>0{ftXS=Ez8sH5ebkcn@yXUBv!_yrGvycK7L=^77chohx%3 zk+l*B_G1IKIH6i$jA!p(Kl^itDj=qic5-j}mYEYL;$x#J^7I@IT{=HhAH)|78H2ohW;q;+G{2!5BQVj`WSQTuf3jfO(AeknAmA%Ff?s%| zmHkm0_+mpnk2XbfN63q&5}r!m#T;=u(D{N#-ocDZ>;KBR#89=$@4&5cMU#81-NZ{@ zFn8&gYqCsh4=Q>m=jIM#>ZaI5>@<2q38%Ub9^rW5;7xumt{8iHn}`RNu9F9aAbxc+ z;~BWBNhdUgUKtZS6=W^XXrex(1g%p5Hp2LBcgBgI9&E7oN$t6&3kYZSDTZHh()z@? z#~q%rF6Iy1AZ7QJr@KSMwf=1F+=U@~)xW!GCcPV*Au#@vF(B*_vf*^C8A-*KgBo_j z{etZoSGywLF>R}m9}WcJc*{*odf?Qb*iuSZ@Q>JJ$Mi1iChvBJ zv>kMX+d53ZOiTB;V&Txz+7fJsXm(&d%!2nd{mb3GdUfZ7dS*-$f4v-mQi3b76Y#$Q;L0;>#x$5PT0+Nkx0*(0?Yk&!_R#)9zl z=5AGUM(uOPG}F*=7q~Mje8R9;iL2f$7R!17&X)^S&?rl`{LIo2HL?eER?&OaAe4o@ zAs-M&%pPg`p7C>9%-#qneEl9)=Sq|xr?&WRf!SvmP^;+15qPHhkt;9M`}KbrG-a0t z03y(S{E%h%@k8+cua^6-c2oEC(p>WX{xZ^3?Mbm#uNOR&Ll#0FNjVrrj432(5*M@h zH3Gv<5;Th=oPf60VB~ZZ_fHaBnzER7t0N{Mxf$1U7D2+CC>%{fY;D;FztGe9##y`7 zc`MQ8a;aJKndd}jEP<9w=GDm;W8ZV;z}4qwrTuOEJBJHNFPr<%D*B9!6KTUiz1GT> zVmSzuw&cu^EMCMI+R?IVYe|xdCry6vn)#3~Vl6) zoaax~KmLlcA&2Mz&I0x_tVEXFE6=dTT2?Q8qFPzx$LUe>9{s#MCWS-jBa#WQ}e%{j3@-wWRHF9S?&Mg z?&3+7n#uB3RPYo)%RrNyWtS|~Nh_-ihSTv!!BJ+WrN_B!7U8$1NUp!Hm*S?poiQGY zBm-fbqVh{qpk<~(9^ak_!=+HwQA2FYec6rU)OSnPdHvk(hR##Q*Q6`6&}?3T1GoJv zA6*G?Tx6Z8l%Vix!j*u@uT?0cLtRFnxcV=Q&W$7`N8wj$b2Sx52TP_(g{(1?kR6Z8 z@*ZcCd>*zt)cu&&r5>kwo_3wl@!_!+or{z_D>49Lr18~>Y5{|Ozfrddd04vJNO#xPv zHxrG97R{|sy|nEDrCRV8EBYYq23uYDwLQBD0%`1VGwt>5ezlt>zRjRXL&Ds|6y)X+ zK?4RGWt(-9We@V-TNi$_e6da5Wcx$x-{heQuS8dtMgSyJobZ5s$MSOW22A8U97%vWrWJB}IL(lU0HtxtI-==NEM6#$lgS zVmx1sgE5(mnYQnLlcPe1bCww-+FM)PI>x?mcNMdbr5tY zB3RTyPHi?{KAMB^BZE4afFDhqI(K{!{R-@Ke!N`pNHbK7XrsKISE1S@pp{O}8IT{0 zKt&&`-XWl7ktI2tSWY~qrS9EAYqq-;(AUMW+~=7_giX>ZIbYsOBYql06*jG|<#g2q ztV14HK02VnTWZ@JOjd2s(e2lzHu{~MoA#62EiFeAC#2JmqXsP*Gj`Sgjo*X4envq1 z_Euu-3h1V!93IP%J)<~)@X@xr?v7A*WyZ~6ZrYNItW_8bp*lXUN0x~cV^Yc(u1AGN zgT9yNcAF@b%+#ciPP}$kMEtdEMMf}T8#|QV7>f;JZ_fyeCcirJF8|PvK`-F8zI7`P zLj-V{ycwnoaheB1HLl&2k4an&bN_n5%al_2!BUVmA5qzAjyW-Q3^_iwuXeZZ-lfO*o@<~}SC3@uh*sZ(X?huL9%1EL zWz|6S8^dYxNZ&7QpOfzi1-R@qsj&5Gd*GUM?z6&Mq?fL%7Lrs6r@}NggF&z3cS;pX-mx>XB84|XD5qB7 z$l1b&s--?(f5pz2U*4FQTZAmZ;XB|Gc-vy>0Ku4Snv#;R@oPJZ6=7^E0pRLv;X#}vd7H&Xk%!*rE>O0NbCT+Yflv8OM13F z&eb|v-5r0v(}7s11n-fr>=QKEusbb*nL18q57R=$IqJLDN%n>IJq4%1TWLE;MXf>w zi?Q4V;{6icBKT^Z9h}yxq7Cfk-MqUztk>?+s;UTb%=@EYFP=>KXQ|Pmw6S+J;3=vt z{WQo5q+CE#epGn&lY@4DKLaGEW;wHdVjlRD__kVwvWg zoaez+1Fi|(s}Bhd+$E4fvO5OP+tEIsDg5iEODby=Kw`B0EX_kmY=q75wc$|AO^V=T zzAYe9g?qD|Ac>Vh^;TdJdmahcHFD3)D8^QIPOFo{3$ z{{{9ZJ-fYA5wm4B2trNp*-uSvZ-v0asH}R~w8R+i2WQ6-(+^3K8v?fST#4Ayesvp& zxJ=Mq9v!Om*RaKJz-rLQ+7JjkG1)R%?X_S0Tu=(!3P(WaQ@QExGN}^4FWU?qyK2bw zPTRB(x2ezaBXVt^;_M92r0+zYvB9NE>Tn$oqr8dhOluS@KI^^t^=;+oE;`XOZ{?xL zmj=z+lfEORCJ!5-_-XbhCDwk5#l8sEPm-r%F;F@+J~cJbyy*sO$CQAY|LJPItPcwj83WLYofnC2fKZ@5ZG%9^WT-O`}_CdC|Gk zds}5CW&hQUt~ZCTj&V%V0>yRUq>=7C@@&)s^GvhaMStk! z^3K5a$F+>xO=)$ThSI2ODWTjqd@noG zH521D?YVo!iVzVXvN08-N1_JvEUnCHaD^C?^bu3cpmUyD6CWSnz!ColA$r+jobwQz z6Cw&Hn4^K)M9%em-F4XhheWmoDI*>u-p)u{mf1kW=JY~)4nMj|(^AC4lbE^#sHN=+ zzOr)h#Eaf0>(8nz8S~m6DMY)H9cT` zZ!uqrhA-JLOUZVM3{*ilJ+gl=3G`eX68Vgh3JED8Lu?3aRQ(9N>{;+MY){JIirn^> zB1>QRS;_5$gzxn@*Kmf)$V5~0Rs%DCn=`AVeU z-Z*mHJ5XaQ$(q@E-@aNTJtF`?r&QTV0uNfU9hCZfsm%CoKRqIG43jJS$*hw_&S6WD z9_z3@$h6xDDfz_Zr*fVC!L&;>>6tdAW2{)-!|&U|;|2S4m0h}PiKLV(;OLomjGof` zYqQkO_#InoYug@{dL3w5FJbo+U!M59^85)R%Iqrgbd!+5>zLVNI`0MSt-wUtRy*&R zh84D8+wX;D^)P1;z_LDF{N{&U*6a6WCfHP7(RwF*xk_lp9-aUBwY9K{YnC>Qe{R2m zJqRvMY-&Fm>@BO!jmbd&JJ`?m5Pd#Zkwqcq!5v%+vz|~IN()K>s%GK40%6m3x6L1M zFV~;9$7V60)9>c4=HmzX*6qH8RbCF;KBtucea?`L7r>l5)Dy-h;fDNOI zy2o-|w=d$P$&q4j%fr49Flq_D3ePUKJ<*JY5np6Vyv9tQ@M3>O)Rn7K9>*EvVz|1nB0Lg>}@lNg2t6TxUQ?;>YDd&0f(QBq~Be%uX5zX zyChv#*?UavpXe4(yO&V}US%1~v)|qz(56W0N|uhW?*05qO|g=G5Aov=?`FUh*6>EX zrsS+l%%3)}lhbC2l&=DsPhs`9vnzT9K=0ceFlFe8WQN7@<|$?YWB~dowZq`h0r1wA zoZOP?;nXT5tUTxTtLh%_-yj4Rj_cP%b;vJSLRb_KWK&yAbGa9^RHzn5ROs0DIrg;n z3~eZUlZ1fh0Bl{Gh~zdDTnl8t5axz;be+VNZ58{xaePitGodWwpJ?c`H@nD2%Kk+xDL@oD+1karHi$^ zd%jc)m)&B@sr?-9AJqPbkEGVU@F@@OT;4B;$MN85!<&Esu6kHv5;si8k(0k+C-spk z;``_iV(_v1zj?!flgg#13-q%Nr7(ByS;j+d;m_{{jPN71s3VqGBdY1;J^hzY(nd{u zUcoBezpUrQ&ZP&JNqFTs$Yw6jPBLU;Q|yZ4nOo2CR{qhU&QEG5b`2H@n|A)hODKNC zjm0^$Qk9IkNu_SHWZ2;OJ<+BqSc5~_$HL}bnW!>={V38)b5a)KwKS1@54f%wNIj%# z&(L|psfqtRQ=#V3eOg;ETv$16Tr z_RP;XiMJN!xr(&fAF8TMP3>1okrlqIWtT*p3kp&fq*-UB=|))w6|+yP>2#GyDl{sr z!R9h#w7H#?+)iVftMZOFVJi#@DpG^z=?W11p-RJID&naMqWwEk-q4=rFD&nPe!etq zuI|Gzi)4Cy&ZSgrlm0|9B$Zp(=cCTlt^+>~uyQsi;h04?Lv#b1Iw{%-Pfsz(_ok6k zg~kGxiAGwShKRzA@T{;2&29j*;UMOsAFWwnq?r-sA<@R+YLJ{ypc|exwENW)+gh)p zTNKGWEd-?iAAs)E&Z|?l?y}Ysh9Tpxi={xyk0If`Bm6B4c;Z-1*m{1;cLSPWM*|L-oL=khnP9fXUKCcIO2+WPdELgklX z`-r%_IB_bYByG7Y_j@^UXGHdHI#Gu>3$*T(rKNUdZ>3Lu7(Sta8}hwSaS%Ak2Qmy_ zgV;bwmuf2w-Btoc82($gl+3MqG}n=idv1wqF8AO{?DZmFhvGNiR{4%>t0$)6J~BAy z_NNkpn8m+gC?l8a+O+;Y?|=8X3&@AmL^-9b&nq5OGF-w)ZV$%y%AvYiUl^y_?tY zl;q|v%;@&_GOqRn`|c8+pf;S`yLerE|M)*q-yPF~5De0fAD@4I{9ycliTeIa_S8JR z6j!{z3B;F^Ym77jE&NcZeKP0dEmlMujL4H4gWO;Qi*-xwDRjSSBx7m@{j9M!66ma7 zPCLlAQfka8%@c~n*8^ZE?Gvr68>^)|O<&AfjVD^J*I2F^s^LC!JZouSCeS-R){sCs zI~*sjKD7^?_}w4JjjBH%Be@_r57N)Bg-j$STy|1rhf8I!wi3sj{-pCRcdy|@hgbP9 z7mql)o}fef_rY8hEoaY%)w|w9tYEN1TA;qo8EM?C*ITcopQiuxr>!o86W<{o(H@>P ziy!vaN|BOKrKu3?Tt|eBswIr1-CPHy%s#gs#*jw?B<+y6!CIw_9m|AFnQb|%UUE@;Ak1jbrm@Ge5P z&2wMz|LYS((((PM=8@UA`eG%PXl6X|Ru4w4%x;(2mylvKF_&`TEf@t&L)-i% z)Wi0-xAOyv5e5P?Lb=Bb?$2({T!ROvs7t3=g9A2+E^Y4qasPKKeKd_$;`LI8o6!{` zO;@%CXZ$=y6P#PCQHg02Grjt61^$9@QO#pngx9%nI(p7JmAseb8S2w~TfzlLE`@Y) zT9)C?v-NmF?q=s)qFpQNjU!R|H>DsH6t%|DEtIH9T-FCbXw8}Z#mI07D;;2VsxbYs z{u<50hOzQ*PDis7XCf2$$Y~3Bip}15c8-1;0_>vvR<#GWFq^oQ?0Gqavi97%OHoi) z_FKo4^|~AMg)+?u6z5_9u-bB^#` z9#Fl>kowSWRN={{sgGC$=wyOPH)&WFjs?{ty_Bs+eu7EE%>e2!qQ;txFDGP666`aYor|vO2!Gj& z4I%Ab6%~`BI+eqk%kSe4qY%0r{7O%XNYJ$C3B1$dW*WGxkRGRKSJg|-&O3OZ!XZ7! zX%LWBIM{#oKg9O7b+POxPfeCk%;RSMlPvR36=g);Uk;M2vl%<_&Hm?36KkVz=G^|+ zrn#hb4zE8YhIr8_=JOei`l-l^k7JxD_Uh7IR8I5-uw7o8i9$yCBFxZN@2w`Lp7 z2G$uRPrV{jOk#UL*3d-PVfvW9`!}TS@pM4g+IZ$f(GD0Do!4wZuz9HB9V)y>3_f-}FL|@JryI{XJU<35s_kh)rC7~_2$5&u54sLut z+RGz5yO2pn@mn6(ZX(Bd$;pm!%`*o`2jGk_pMWjnJ$o)V9svwY;{078)wE&nN0+FN z*#Sc9{IB)&-l1VL7-~>ipVWn!I|Wj7%B$(oS@e4`E;<}FI)UmPR*v3#bK3{NUK0*d zmT>^HxMG*`m`0N4U1%jo5PWTrl9kN0c{|AuF#r}r68O8R?BVa>V-oLFNGYj%!Z!z-Cw(MPEyUhL`D#GmXEj|uZoAQX zRbp`S@OXXyp{hWlv5LGdjfnLPu48V0% z`FD$se7y4~Xev7=^D)Mp`k3^)WQZxGuS2sv1x$WB^1W;e;0{|cU$7#|;rQxAPR`#E76@A_|I3LO$sw?5;q zI_eer*UHKhbqhr(5@CzN&Q1l0N+-GBaJ{@sYH6hT(ZoA)cvTu6_t*V!RF2H(d_!Ai z6Mo8PaZ&SJpl{Rq!RxPUtV|PN)siS+bs)BOFV`6V6|<6-j?c*Q!PY90npwrf)ACvL zyFZMmeRI3&GUF()L%>z5zU!O3Cx8FKtsCSQYubR#X{M2SrJwOnr}1sic1}KFn4vSl z&^(=~#gu75p%RjFtmXvaqqVB>8>l2v{e02x#Y(Ge;H$ITfL?WL{gK-=kO_|oIJAB_ zYF>2x)XrN)eaNN)(IF#>#5V!$QsftnwycjPs&Jc^jvy^yXiNXX<1LV9|G>LHNFt28 zMqiO7J&NKOkJoDft#7VHpKq!!q1dGX87-j~)xi0lD}Vt5^lIqm6jq@?y96C zRNBGgukv)sR{xXj-XJMyAyiZLYpKMq9H^w)+m?_Zi{Dm8Q}G&aWP{!g4-W?$RrIbT zlh`(j#H00WzVlksvL;jpPVG(G`RdYCJtI6LFux2Be4?5M)89`ZJ__a;zSbjs zbYUq;mOt?vhEhk)$1}EAl|Iw3YerU6jA*e|l(iAL4bI5LxPel`8@I5pcv*beo$~2^ zykPE4;>~Ezdv%=nX?M2*NNI?7(wNBzmrp@hy1%{>7cb~ZsBGM7q$F%QO^Mg^;aN%0 z4th;;7?b+t`a6iKZ4X&Gt2;I>)6mj$mMd~SMi0L?yPx!fvrzd8U!qPQke_FXIKibl zv;SObyp2Bb+Km8R4<=cUYS}g)wJ|r)+^XNK#-9bv!J-GFqR&F)K%~-0DNGGE zh)#w+h@T`Q;Cf%ASe3+`2hpWvZa9$Y;FF+0g;$86JpOr-#~XMZsn+@mC~}F|IK(U% zM(@R^>WwDB5kMF*iP#cQzRsiKQ6iO6_GoXZ5lTKrRWA52El15(CFQJ@SX2mZQjN<| ztk}H{{PnbWx$Tf|_~DXX;Hon?e79SoZj}yfNdq%oL{l)>re6K)1LHcQ?<(ai;yqo) zci4?nZF9?}#20WK*V?~>TM4r?sc###{!5@4(_Rm7a3Y7VAjpz>Cy0pW(MM0GhqA07pQ$zXx?gnw0@ePaBi!j(UeQSjs=2aQnB5 z_eS!FoN6J!n`vtmKDOqJ!bkkt%hqHh(C|e{j;P)C+#ZMY*LBnzyO&Jrntfqso&u=;L8h+Gsnhm66emlCrD!&W6!VMENONA&cqxwUn7Y1b2fNxu zMEu60sU^Q8UqxenMQ#BXE(pw#ov_kTBrv}t5)@`F?n|Mk*)ZHx)T<|+>DEr_eR#}9 zd)7#6&iJ4iu@qu)LaS~`34m7K@G3Sql#BWC&aSiHn1gR0rdf#6OoBCpWS+2%dK*~~s;25B*8K zFI1o-b_bS&<(xlxJqjU^3!3oA4M%p*r-sMRhC8U#{K=I4on2>`{MHoysg_|;m-xe3 zYmlw&h4W5_yxKNjR%tJx4ZqOtr>6~&%#Ih?yDEc7!0!7FhjHuy!X30 zLomWP@zC_5SPwUfH4CzI;@dscGzx~{L#Ooel8_9WzQ)ZbedtU(NXkk71!jrHEfmnu$D-A*I% zyhmRL&2D=+>GpkR^kM94AhjC@+z}%#z_g~xj~xk@+EdpKHlXi1#nbK~DbsNYkB)eT z-@e(wkNzr_h}>&O9}Nsy#?4oka`gx-NEA{Ju-Yf987J7?GDyt_j-i8yEQEKVQ~n2K?-ZpA zkSq(gwcECB+qP}n-0j`A?cKI*+x@j|+wOnvJ!kIvXC4mLs+X$QtgMWPimJpem_ZZ* zeS*k@1C!;n^t)jlLIzcX*o1(9^I7YI0i}WK+Cdb}WxLIcc#b5(7LCH96Q(`dVNUWo z<@r})4fmq!xD5gXBn1uxMDt$~Yf%^5fBt*@AB;LgSw{gy3GsX8xh0u+14Ma34LBf> zb{8BaI>JygQn_~A9J8kV1l!>6J&wXiiO3( z{W!~kUcle?2iOp;5*CoU!fK-*^l`kAT8Hgsr5>V#a-I7o%bi8H0uy{v(uWz%EwEcM zcm{~F-f9U%HHIA@9YE_x36*F(iu#k&M-kq9-#fn|(#@`6luqmYovYOvR52SuBX`}Q z-=}6|W$)RqKIycKxliw~&g~jnABw>_z;*#Cr_vU1)w#(eQ&T||LZ%|g0;~DavJ~E6 z;GE}76>oIRJY5&pEWhuP0?T|VRfxk;)-a4G5!R2GrDlLUR*WP;AX3A^28su&H|MCr z=Kcl5%owk6C!$KZijm0qO>woLZb@Fw$Fo|njQMVH!rdMWj5CzWE(g5QXf&2uc*frf zs(%1Xk+;knK0jFv=o;xNzcqno5bYYMk9`EfZq*#K_olnT@koEzKpJH*Z;&m}Z|JlK7BXx1n!>J| zUGvEofUi_;f;!eC@g0E}=tp9`QY1n>p$x{lNzo}0g&E8or4%hjE9*v3gfh&4`RS_9 zu;z~6Y-2j`r)=aXv;mU>B1bT!QgCzy$m;i8Dmi}QHu()*WM_AjCUg>+Yzkux580Gq zOE4FzM?dLteO(lO1X4sNvX>4vvS&oFsIsxDv8=JKv9R&ozgf-XJSnIJA%TG0k$`{% z|GP9%Ft#=`as5vr>Yq&#oXuR^Y+e89GwarPQ^7gG^5b_J(+@`Cl#1CRw=RGnLS&ZD z97Jr@l{axO|jc@0gxv>7Ff;+rTPSF2f_<5!HGkS*+0LavvK)*s;8rQGET%TyjqQg|5uS0Jvtl?7A zTxN4<>qua&Qu7v5;O@|2>NfR972eIcL)&11qp4hr)w_e0@o1c^EUq&0>@X=eXE$(~ z>`=qikO?W^uJhH=*DBM@v{+5e;=`GehntCGsI0VJRvqu1@F5KD54UqtRyxY+pZcb- zfEaiN^STjVro;xn?rrQ+V*SO6fStORgXE}u@H7uAh}$-p(*1{^=w0xos;~3MqGae} zuoJeL(9B-iy21*E_Yqh$E+4#pF#K@ z7gYvTbP87mx{BNhNMoVhQ+>C0b>7@`xG7AkfbgT)g-zx|ux!M24(Ihb1IHodP`xQ(i`%4*RS(ZJzi9EIzS{jvr zOi>G4AJp+`Blm47c{>}8))+l|D9QXoZ#B^X*%iOPTlBNl@I>g+yl*Z}JKRTP4^(>PRmd-y<$Ri%RI1 zj1T{%t)bBz#sJA%Ju@&uYp;>1Bji5UW@A9EBRu&v^m2|fthTP;;(AWT=mN=mL6HJ_ zaWSx!geRNF6@S0`{3hleX6r-h6NZ)=TH9z?Rbd{2J?$UP%Z0hT5!x{!^ICEEBKM5T zBO0;40TSk%^p=r7p1{sYy_Tmyn!3 zywTczWywQK*1Nz__ov3uZPPlhDt{xMKYVf-}dD4_W_fWayB<|9Igg; zCeP>6c&G*HTexxyY(=VFb=qa*WacFP(Kuopd_a#p^gG-~A5v%8r23G#pxsIj)>}Ty zNsO4x)2H+H1m>bT`J%JqGP^veyt*{_7PPxD*W?18z}G2kb_9XX+u_yB8H}94f(6{l?gPIw++xg686v|oo*-@m!#-x3UZX(ExZHM- zt3b~yo&}2K?M_pvxBb3k#|t_4f?S49+lyXr{ztd%Z3TuS0Y7mk?+mHX-fV1w=}?~A zKBrVS*#;}6F2Lk>80`>QgYEJ6)8o!uZ-@jr``zIgIeo6zXsHL*5`mPGGv@W$5L8^8 ztx*+6cStFqI}5Wn;@kB=j`zt!*RoI70-$4gX4^qj`z&kD&h$ZEkkOj3+Zijz+Z!-& zHZn5;>(5a?SKkC}1G*jaQpdgGbcEjX%5Y}-J8Q>@wKonmvoKuLi7rw?5Vcn6LJ9sx zWod)Lc}Ku|TVUW@;uZ70xsf!OtY<68qx>7hRhbB_&#-Tjr@KA-3@X(V#O zw$AH_X(;;jEaEH<8-Uzrfx@~-Vb?6LDO)N?93;puhIZ8toHq1Lx+kjC+l%A2nojgP za)teNM8D$>dyBJ2LMTMw;~T!Cb|_5p1yW7IC`zYzBl9{x>rr_D?$eQBei?gS9l082 zL%Kjq0PK4#WuQCU{{XWnjIBZC-L9_=*#CYM^422OCfheU-?4WxW{5Y--3J%p{Xj(W z{ea?@Q6lYF>dk~>fxiFD$!c3RcKHLT{rfjQT=vOriO(%c?5h>Lz~Jl`MQe|01R>fNEYR#!E745M3EV*z#3 z(9y!`m~p^I)4x$CTn^xZ+;?1VQfSBTN$O_9{9j3XPBwRLvUfgSczRvjKBm(I5De4W z6#i1>=7z(TjyB64eyS4l^P7u?BA4%pT9Z2WCh&5zmW)p0;ih_k}76Fw2P-dfeLjk)F>`$NhsT<=pR9urCZ3ZDGX(xeXtHi1p1 z+>ecBzX&6Gvx(1Nrl|vru?$^hFu=(VrpT~TjbYiTLJz|ZqZ)I1Tn|fx#gdN$5-@Vr z(x75ajCCf1NuFUVE;&qYOXRhU$fPZUBYh6j0uziIGzu6e)zZp6%gGZM`_rLgc*W~Q zcqjj5dF=O<&7Q&vzI=qkC)3W;!gnkJimhfl=pJAQO)k+S73I|^cofXORw-wF7woCj zw`F-{KMapX7Y?<^)yZO-kCSk)OxW52fVpeRjkx%j=Kntz5}qbg>%Iuws-pEt1t&OU zY&8f33(-o<8tIPc43kIRs+ZyzXVbMU%a>@k>StSWab64eus%3Zw}(5 zGWkya6NikSExlI1J2>c`ovS53d5I3bQ?rjm)qA6x3gh{HTQ)6(VyWdxmQs&up*O$p zw8c1J{$7`mr{%$hPN5zl3viMjW)ufo9PjG>4xvH8Od^g2aCyQ$L^ByBNkgk~ppf-1L8f(=oB)tdcG7m;r zeOp*ExC#WI9_w-{q@q|Id#~ckVTR1nrmz*ChxAn#Z84%yUD%G%KVbmO%forx21=U$ z#DUuz1O(gHJWaTNNpuj9N3(}fp50aw4G}G-Sr0RKv%u#mYJI!J{i9D-SX(+ zDvjQ}G$Pj?J!u(14l|=l3rpi(^d#lMcPrHl==vSV7D^j=%AlsyL!dAbc@Nq^%s4ox zr6&ScKqRW3?{ZeY=t!9ZQ(R4ld_4@=?wqSh%$9?{g2(0sC~q^zU)-l1p^Ycl&u7e! zxnO_&2NpY;Vr)*9YSo`fTgZ17<&XgIawrcdyDJ|@&KWkwQP9m^l%W-9Y~sR2 z?$&1J>O%>4_rxYpzeA;8`R!O-X3CKKHyG4q>Bou}#c=QqN!xb5jMtH$tMtek*klO34-0eaFe-=tn?8V16-hHU!~!! z!my5aH~3Hw_OdIKF|9Gd7OXX4*5$Q{chWC#QX%@Bh$ma*%q4&PvrP&!AJqg9m8Z?W z@BsS+0EY5usbioc3JpBTkZPIq`K^?@#;|LV0jdmfA~{BlrY&DQAbkO)GIzeg2);7D z=s3nsi-)iVh0{dGTc`Z16#$zzcv2kFyQ{gXIWoWj%3GS+tkoH45MN?kIrxea*1|Oi&MV#g8!uo=S);Qm-oyFt z(-HT(Y-XvvbXsdDDuv3j(43veQg7ux+vm1(0;1Y2WEb_iGe$b$<(L!upt>l_>D9~# za*rOjW>iy#p(OI*=sDbM4>}^Ni z^KeXKpOHUVqmvmECChFt%^k#dC|w}jui=(?Ww7tD9=63|@9`Z@c2n6UmSL@8R3JYRBl?YN_G6eDq;-@%}r^9s{2F{u+l?>IX zc3+A$x206>4$p`;i_fS?|AF2HUlU0UTKeH*%(hMm=yr(PwY=T|GPqfby8d5 ziScqhZs`uB4ESw9k#q*Xcf{iCIF03@(1q&rUhnOWatMdkMyhp2b{x%Rp^Se4r+rci zJtDojnT}tmT+fkLlC2a5npm4yJM~1c1=b1Lc?A^1WI_-cr~WyyMxue0WjP{RMVYEt z$ZfshNOeK08~9Rilo|a3x;vS3b|wK~vG%E$FLhmqs5G>#mO84~BDFEx3(pE&Xa(Rf z*pq!Ul{)6vxqf;0j2~bx1GVJ_LITF2$6F?Qil!s@%5L4_=``#o?GXn1d+X;RY9z;w zZnuLP$Kf)f*=TwZ%0*5#tscQ`Tq47Vv2&;`8;Y2L^sp+XanGFyKd;kO+O66}%iksK z9x|;}*6nTZvyy(~UF3nLD#70`qKr@Np_EqnxriD4yKK7`U zf6vLA&+o{w&p_iwpJIJkobTIhs4VOpe&r)5d&z=Gsn)-{JG2erCv@Ox-uh?6i)|NL zbFM`$>|Ao=Xve2({rLC!a_1JZ@ELSN`biM`TlZ7IA09zzV1{zV@0N*{$WO8-KLg+9 zMp6FC$oZuw{a2qiv$X2ZE3#Pr3C=h z8|vt$lXO#^p)0hfaOxNq7qL$2IVC;HPhNz4DG?Vh7a}Soko4(Z6LsN{9&os8{<3sJ z63sXC)&N0D9-cDxuyv8*<(jVCcu`hY_8afY&7Mt+wML6s^sY)dU+a(M=hWv|J-sVy zZ`Sn|wsqty>zmc-v{*i$vs3GxwbZb+wS>5{?3D@Tu~tboDz(vNcPa^)#f)oP8={Mx zeT(}C zRS^`28jx?(3Eh@Ii$n3bPJ{tHtO(UYbV>0y@Qk`s6fZ2oz#;~;DGMyi^>hNqZr_WbU2TW*H zSwmMHH=bdiXJ*zCuP!>H;bdm&3p+x1OcQPHsHrWV**{e_T4su6?FdB8%-k>2&%#~u z>*@HxZMn3y%1~WALh!BB=V9bT8VyjkB7`Y zJ!}NOZgqrNg3*Oxr}|wRScm9C*_I|!YvzFAILqO9i(emAYXS@OR+&hUQg&2@zhpTQ zHWyRsh^kDsM{xL|;tFVdMVi{h+8mL%<>sI8>kfsxkr+QUDFB%b zr(T2-P>+z)q|^Qzxpnx0=G-3cjLA4|ml>P4-6WUU&9!2CW5dXabs9E zw&ub7h&sU@?SXVr5=?Qcmz`3GMDl81LsJNL)Ml#m-68hIr0mBh@#Y?TPs24)dq^pe zRmhuE_&JVi7E1aL+N7x`TA3rgEK43Qb4k=TaU|;c+>S-m5uy|me#y(33a}eLsU}@L zDgv@3u)@ge?mIT3faz%^R0{sYDg^77W$IswLjLR_4vahSh40s>mImfs#C`oU)t2Vi z@y6{p2Tne6rcrli+KA^Q@W}iXt>trn2S$hxUqx(i#;m_1SEw z?3L`Z;iw+Kb)~SeuG6>y`wDLK=6CT%++)h4r(1%=7rfO(H;4R+wgE4q#lx#?8fKtl z8CL6>0y*ih-&$+Pl4fVw9psrrpxa+z_=dav*Y|rl zbcHN1kRRI{ipOe1 ze!0u8Et>C@vqKZr9gXePOtNhU%hwqv}SUZ zUOgc(i^oPxu&QPM+I+@|!zH#eLSq=4yN2`6FPkU<%H;^cgRVAITS|IxYdOhl&$3&_WHEPpI)&q0XwCeVw&6{3l4x@J_}`31Rax;hO8~xUV593ZBzur z{BMW>#0@b3mMzmQz6dLhB3u<4BBP>%Ak!`zqh&by8vy_UpY1k1AQ;?=t!_=kUr2Kr6es>Kp52M$mGK}&94eQs;j zNm?)Tzp`53(CgC$2q2(g#Q)Co`0uRt|9KuYHdJs{@V;Y7n~W1;qU(h?h|LAPi8!R#T&FB!$uIcfqYz=4cQt89lC{dS5d)egcyMKVM5bz}bTY;9dZA_7>Y}oAy;6Pm=B)^U+jTmo-KW zlO%U@cPlvsp0vntDv1C@sA?-5ZQ8VT|YX2rNVtXd^=9*OR=eNrF@T?!>~Y%U;`xg+pT-(KX% zov!>wvNc*6NX|uS64R9C`h+QFlTn7dZ2`#SAsF~N^;qYRo6>z?=xLiqzU zsVHdH1a}?_PGkm0)36Ge)RcL)q>g>e_|G7au=6pP85pp%$S^Ltq|$E}`*GI;DR)kbM16$!Y-yUEbD9RsQWW_` zh$uLk{<0h7zfly~QNcyQd=C3kxWfxjU}DH$30EHIscQ&P84NI5;#quU>$nL9HTbL0 z^L}(OmE$n~3Qo^ElD~29bEiYuGkWWJwCAnB7*N;oimJia_VxRt)@TPE`Bk?D_%rjS zcCIcaNfDJ4l@*2`X8qL#cXdRJb!iXn6*Zf9F*FpY=3b$nH%?>&ia&2l!)T}E=&OR< z<)Y?$kHcqVZt6vi@)f0*@+bP&Yj~+mx^eQ#9e0lcWf7#t@@W49^MazYgQJ;84l z=(8TsTMlsXz#yXgWz76VxHZc4lD+e`<#NOKar5JM#{h(JYXMx^aO~x^HdP+-SSiMr zrDJkNwxziov-C(hxkH(bqut*@5I|_?WNKt-F1Mu$@JtzEpsTjhvia?LvpDt5?g2Yv zQI4mjiHVPgPUuZ1iiqm?9Dj~8Xc}0@Wvg{%gN=ndsrs=*T zL4QM6;Ucsc4VEo$1``(+&`28y0W7!Xt<8SI-JXwbNk^r;2u;BMBD&Z~DI`uCDNYGu zjqF3aZ;4983a$7|A5XZ~t_?OvEI5%l64|3o>$nRN%!9noITA7o9$t&)I*EjPmoF#G zBk#Bb53DpzS!2_J2CWwxk~9?LreJ|O2CSu+swKL4HE6G8J`%SoWhX{=Tn)VrU#U2y zZNAr`J!TbR6qdYB*)I(hUS+*ZTTi=dS>RDC0vuDssMHU8=M{FG;lZC?*GR!EoY*s& z?CRF^Xdcm80`tLhDixJt!$#qg+_vV$%&umfI#T7VD|26&X=WjV(N29}T5M^W)77>A z{62p3Rv}HoKbnais-ee$x0Ef3Il#E{gETPeewQPwsxwZ~qbX-YdITBIaAZ0~kQxmf zX;n~>h=9dIqhG=^VibxOn*nTWj;xZMK?Be75N2xL2k+33sqR*{DeK@CcvYEx5t@2$ z&Q4)G;;pFrz-*q=C~V|8^04%Vy z1YKkn*OYx)0%RVUbBdm}pn-p=1f;IHwVh^lbJb&xx<*bxI9!I|^1NMpLw>z*G%--P z>f(2`c+|QIPoJE)$&g6f@2jImwaF#VU7@(6f<+Sq?pBmOUc811GyHJ86)Vy;zEhI< zIG2x}1l-G^EVXmf08Fc_*FUrvB#4Nj%yL<Ed~G4U}};xJecx(I3S6N3*V4 zA%p>3v)y7;^L7OD)`SqmzhiRG>tuR1@u$7=ij6v9P3f$4%b!l zfNy4K=qZTEzrBqe384)#e^sqa!oex0$4W^3Y%6$-VmyWi=Kf(UxVde;wk-`&o)RLY z1Wg@al`p`Ut3Kc?$2&)$D#}C{R)(QouoyKILx^a!(-^^O+#rlCaad?7xM}lujr|rE z>y-R95m~)0f%8k-fzMmHP@0TqF1ZW`I^B%w@)R6 z)h~?GFYZeW@M7TK9UW^*`rua?hf9JFGp6MrCuGFX!dccmlCAcI7uP9);^5c^YD{Rg z^4hUHP&0?8!MIs8*r`1=X3=svXR4(!SAL{(g95d+*cEEDxBE^5y2qgwt|D1(%E z5e8F`c-e{8fZ42C5BPMjdK?l5aV<)IpLHaCqsmMwFSV4z_m6Bb-BEER&k9tS|BA3X z^LQknkTL3LEpQEB+Xs%_C(^DLS_r+5 zfU7kJM?kMShI?*>?Nml%$|-zxR-$;?9|i@^z!pN>MKZ-6Vw&K!xBF(u?ABFF^-AT1 zH{@URgJ^9%Tn5(@bs6wS9E!8o#|)%a5vLSrz3i%AM^q7+DlM0_cc(4lP+#UAk{zC;5-f&eLc{#8{lv5a34X$Cl4rD8B%EQh6+>{fw5~qwO;f5+Zb2DkXNkCe*c`QLSM1nKp>=)jkS%KP#tmi+9(_BqWuj-({~9yQ%<~d zWL*MMa)oAPm6NpsmImkD@%op3SG=w)LAyZdj;E**N<>39~#}A*8lOkrw0WqFc5b`JU%yg zixQg0ED0-M629?s1*->3Tpoj!JY{Rr*LFiouI(B&H(k}5AeUDmAB0U zx^rM4AQ4a?Acp@Et^Ctx`7f8$G0J)l07g_k_YTr4Lzzhp4NS(+KLMiB27<6e{UTz2 zZOcTuP@&ta)!L=-ka*DI-spG<{Xh}Q&bMEcpgJqT)?X8icd}e&yPx@1c6$8ZV0QUi zpxq(PNO4P;CI?>HF^5dho4hA5r$r(RFY?OqbwM=7tgu}Ivs%3Lu9ZG_5| z%G;Zjb6B^ICmL}c;^^VJ-YixGXmv+(=ZV`MFwWv)7B_QU?QVWps*oqeBUi7N(~3N> z6gXAP!761kJ8*0Q?*lNUN*<;}Ydl=ahl3@YsgqABKG7yugKcv8-pUz;jtHk4fgPfT z4sB^&wsFlqg;`BXHJLPStGbS>Uny}8BbNx!blH|!_>7ijjG=i}wO1$G%xyPB6*7=Z zy&Z|G#5Cg&|6=EdvLMau>mrEm@zOsks+ZB&QnI4@ghaulq388;`2X9}*AX@doNf>x zpz?pLQU90QM%v|H4Z1PPG72b4sNdI3Ir3q~1j>{OO7Rj(5Q+yNCV}LnY-q6Q^oBzA zr6_H_n-MOhG1sqjHM(tcq6JPyP%{3>cbo2DU>M{{4;?2TGd6zT@86((CcMV!ZeJ}b zFIl{f+pfVIwv?shrg~!{Cn4*Xk{qZ#vVlydI9-!D=%r~_+Ji2Gq{gtvC`4kf$wt5P zofGPCAcpFCS~bnm%U}ER0k;F%eIcswX$uJLC2BLN1om#))o!6}hjz1p!;DLhgQ@ZI zg&Xd{u-$_9sVb?NO3)f)Ml7hCB(rwnry9J3v}DXv2KEIzNAFK9=#^GQOqM1<9GJ$x z))feCNMdnCzPSnEesQQUG7iO~T8nfi(SvTtm|&7{>lXnJkmO??Q~xw7Vsm<)hUhGE z49*SgKP7heJYzbiSmH5s$FF#foef)hn79gvM5UwuHP2lC%Yx+)(xN_AQOn$<5Es>= z#A-|8?PUwFFm-rb`eUbQe)sr$TGMD&{N8*SlSSRQ=%5J2;%2(d%sh#w-7niG%F)DW za-xLtm4XUpr15zT(nSzM&!i(ymi1F;x0$!ba;iV-E-i~_@Ks(SwBRbmOoLL%#uEoa z0HXS#-GD-|gt(}6$!eu*m#q}n7k}rdni?Vaxp4PVt>(dhpF!Ds(*t1%}e4Ivjl|LfZ@42LxyZA@;?yZ8>GvA?!HocJ? zW^+cJ5xuDtrnnGa#t^aO=LB_34Nc`bFXuK26qu5M;DMrnfW$#B#2u?_J5d5QA}{*CXyH{IF%IUNcn=KkDY_6`N{8$WLxoR^azlRI{ z#H7;;{lz6_JU4ypOeO+tID5rRjRm=($5QI5kCe9jS-v!dab29G=1?rKD(ap{Jda6c=$WFsO)gWE&h8T`{bblhu1O zt^iu%yuUC;WKLHd4t@)z(*mj;Rrwn25cZDXo~iN%EwoX@bd1H5zYtMgbUB3yqPa4N z-W^q;FpBGUpord#u6ch#3k&jdgDq{`F*+&@;y9%|@d(KLC{Y!*$y!&tc?_a4?kp=| zvzx`D3r<>^Atqgf>`dq2w+n3a^TN;Gk5yj%ZdA3piP1Bm2jL=^J$+ z;zFXa^XU`d;6$a?psh8jpzV)Q$=Smpja9mu0g0YaP$nfk$w;X9CDDCSNj=%JMj}ol z3}ES?_^m2BiWY+slNEcIP1mf?U{_J0FLe@ZOB;eEbJBE7cmg!hr(|HRZpVv6OnoYv zlALh{b!(R}zJRIaPkPBX3G8u#D-jY;A<~Fbub>v0yS5TA}y-Y;T1SK=wIO7u1 z0NHDOWPZaVEjsJu`4HMuCg3S<<2~0YRKYHdvl(xFhfDzF^%KZD``FP31EFk{8(5M) zb_yq15?8#^)$oL|=p}p~Blw)}dUalKy-|aVp%z_ygE(VU4Xv9e%0x_yZjOTV1}g{839BQ^*J0nPg469p z>n7>!93^|TBT2gFoE^{xj*#5Svs?Mm!2P$cP~(YT4OET1vo#Q>-2IIbfB5t3GGiiLY*f`_;P?K# zPVcr6!iYK;I$ZQSNkwbx64fVY=PK<$CE6yDY;=*?%!_?=C;!let_{4})npX;v(f)N3Htg&Z}ed5#k%oB+qXQ zJ!iPzS!V6-yC=sGpu`D$b1sgXhr*!AX=_s9X9rqcAg*5Gz8Wl{tq;Oc>p=RVVv zT6ZlKdueo`@Me{GX$Vx(TQz9SNj3SF{LQddm$I&`$X~GXH@$!m#J+OzdQHyu@YYG@PgC5v!!8nGX@7|JDqq=)B}Jtix+ ztporN{6M+&$GrOY`TyzY6aU$n!`}f@m@YkSk?eLFoCXsz2d2~qjoB+bIT|9O&N^w|Y{X@!jisaNPbt_MhVJvE0HSlUiXYBs|H9%QO@rFVZEDMHnk(YhIFGxhVPrbn1X#Gkc_Ymp07RszJ1zg%oa_~tq?2}QD4+f95;P2(NJvBVuQ@rl? z>J`a*55cwUf@M9es@^a5%0LZQ!D}WRgqbSna4FXw4eGfrSxVK8CNd}m-mz)kA0@uu zQ9qgPQyt6nE$;;#Os9Dk;a+(^h&YjSgp;-E(<9npouiPH=nbJ2m_2We(6U0_VM~G2 z5I%2^pdotaV2z`=_w~=rpLS!rEJjTW?MRRg3aumfdslxlE0s|>phaphIcP9V_mp$B z$Z@%;7Vus7bdSzMc>EygZ>+CKy7-Q#s&uqV*C3B;nFB66HNhRP^FhCx!VsZqISkt)8Ezpbg4z-v_CKqn^AneX$;ZJsZ8^FTj|uY{oe%A={~9 z70vv-1c zA*DVs1=>Mp`V{WI@p$e24))DP{>VXqfxV$ZO|-=^?>pvpH_}frC1M3NS>< zVzpWCVnB$)>9RYH3r_U~vn8`K+V2Ma{98j3c}9h7w|~HU=6|el{*RE2q>;U;tr@9^ zgPo(1{r>~|Daz9dC_<<|)7LFq^kNe)VLRH*f9nKgqx;h>BQ%v43C}`6Gwda@-Fc#T zbOsWV{KF|2@Hwq`F$d-aCSJ{Tlz4@hJs)p&?Cb0*XgoplOGWc zh^y3R9A&oR^{17m3vn5+ahaOTd`h)~8GH?9WiG~028E);aTSP8 zUL6L)pejg6E(i)w2DZV$_2J2C#^{?!Q@Utu-pvBm2b--?$VvxSZL^$wpY3ceSZ3LK zLPYJfnIc+nZ*bWB-96+;SeYYj9%SG-{;!{rq0)?K}H)RpR2LHXKr=uKt6T za1HYdeA@SST5ysE3IwIulLuiV;rL|To%T%NmBtt{H!a3F|P^%`f zg-jXPrGr7H*~|vs%HKYxS~XiNx}$t=mI`*>;q zq^pW@)OQsdR^h*+IE3$`7YqJ`x#7#0yyS#bC==HN>)Ht2JZy_^aUwDlMaVh0s}b`Q z&^MFbvJ@8tQ>bd1M2sA#>kp8}ET`~u%cMGF%6OPx(`=Fe+@}(zN@hT=OPRq55#p|m zV2kiY^a-0HxONynj|8rF1$0YCi+v{zi)M35H_kEe`J}^ap-}iNdei4e80DF85XSD6 z7sxgTS;-i|Cm*5yN`FHS$lDLr7*|A|QJXuqN7A%u{{sN+KmghTvNmp;xLR46v9)40 zu*qV8V**F;Vmf>OP2kw&()#`j4g~ZL0|Z3>zu3zE$yU|2Tu{_df3~Jq%scH8VTuVb zBk2;+*PGCUDD&t*psj?oqZH8`5{tLY8*$t@uznBT3H<<#Ds4ZC5g3Y#7K5_LS37*m z#U8YGZjgUDmH-~Ny|4LtU1q*=TxP$&ZYl+UvI7U>skD}C*D4%&KK|CEQPaq`lx3N1 z=}sKy;0&WxAL<{Nugw`7!j7YD?`j1B%Kgzr!inmO+8Z~Qa58Hr5GP=duvA?p$tNqr zgQ;crvDWo$W}43|MQfK~L%%XU74xwY{l&e`s>ft)a8Fj_qrnCZ zQA)GmDht4GttM%mUSJs7Q@^P6Usmmkg$ zzf#r8SI9je-u(OCB-rdQo`wh9k14|3Gt-YlnMym?iy}Gd6oe?ADGE-Jl_e<5y0R=! zo)<8Ky81~6d#31dD=a0#gfqO7G!2g7w>ql8tHL6la783gRO_aSG2xTe;MCASPc?4Q zA-avn$~j3*p(U`^n;_lnIRlz&Wc|`Qx6BmJ&F8&Nt;#t=%LEB5!ZoiO%voHoMVUl> z0R_zNEEUF3g99^Q<+)38TYNVL7X4AqLc`G6s)!?0)K7_Hjd~bvbIwlGHl8H3p-4)9 zyy*>%zV-ILarr_ay5rD^DJc?U`5EZxmqvyKnecau6OTEA9(!uA)e~?Y9?cRG9l*Xd5PwIUv5oZpDQOh4hj}6*|1_YWhm}#)KTDokxHf*xer7613URE8=EW-tzS5&4X`4ncd zNFm};H9HB`rlqw{S}9bp3qP)sG6fF-NqdOVmIX;8xYZZ9%9u$%yx7FCL@v@%DLY@) z06##$zY7acVQG+Z3A^A-8yh^g@&r@X`g_)UUpe_33uBvDXc|+`W64Q(FKAkRg;{FC zQ;@v*N@X0r24|~!4Q|;+>wR2gJuwV;lC6n7f7K2@Z$;B*vXz+EXPDR~JOaK)E{ z%ya0}qO*DDVhoSwHyWn2)%V%yjrE&6{&k*QOlhfkNK9|-OY~+ zOXvXkZMx@xc6Np734nWA1!6&Lizw1?Iy_?+GzE+UVeKqQne&vK1#4UWX_rD3x4h>0 zrYNEa56;Cm2zulZFIfod{gxlwE5+w4@nE6)MnE>2HBZCPH9!p0jDcv1RkHTEI7r6L zN5eqE(;4QOj`#V^3nf(rmQ6-BZwUJ885osT0^2*}Js9Q}bnl~6u}{~MET=hY>Bv$+~Wuxz^x>i3sZT^t9#H!C*;0>Hc@5F#{mkWT8yK9~m4rkO8p*^((zOHn-U zdJ7okp3`dT;f+0s4$%lBh)-|nqu>-XaD!o!bEnV`6_hCY+1WC_Q!%(vSxOKdHl2fY z%O40++`n#-7I*)SAo9xnDlhV6&*G`pokbX2P}0X9*l!)UjmGio+l3vWk8rYev;(@D zEdP;Ow@z!YjWas2Mh<=CQlfJO@o2=?lgpf;|KQBQl^4(e9mv$*6}^6JIA6=~HRqt4 zk>kRN6n2zz(EHj*MPFN(fD3vln1a&hO_ausgNVS)y&a1DC6HWbM6EBa!@3=poB--X z-Aww}u3St+BVAlCao)Z5khKh32@R*JSUlrfv0UCV`Vo3N8U6C7K&)KiQ%5tMgcq8Y=Wb72TufAN zZ~FoP!hQ;m!nDazpWMv6B1%`!$7B_&(3Zkyo6km?X#?I`r~DaF*E)uAa}$pTa)d*A z=Xxn)b_G{)e;LvGn1^;(t8d)TFJo=N58AexH$J&YoV;al>vVPb%sx=tA>wh_=4BG5 zQaQCK`5$S4lU##)vOoCy)?C8jS4bB1_oaud>2o*XkZYt4m7-P;kU+p zrY*405X%!W&w$5@d500*gAt2Xg>hSAv%*?$s25cmUy91jov0=0_0ejNl#MAnR(@_* zcnJ^>FuX&p*~;sxlMhIdSWA?;7}_j`)W0v5M_-5Ixw^hrAoH}!T1gCzIOiboxK*ua zxQGZj8Q6aT=UScBP{L6J92@ZpPI#UBlY!yMT=5IklCPi$ba%p8dW z8&S7Cr-t4(>@M}~O`aY_R3NlUSTC%Wx?6xkRaYeGV(U3@U>A)>=@us+RvPSi`&HFP z_(f6K;qXfaBoa=?dWS1TVjvpaZg1&_Mv!R*PEQmAdLHWT)2ZL_e=`AT1pNY)1_1(s z2LJZ~`A-kfzdCBzqNu#}dezI;+kpTx1zi&My zhVk2L>fvuv$bBK{Z+bu^Lj$6v07+M%XN zijE3>IKpZG~b4U|MC{Y~_ZfJ$p=)CNlDk&PHIqjhuo!%;d3IphR64Ho1+QT{C^S z_^vrbh3sNpy_O2|_h(Dz9bmeZI{LPmU(_J5ouyc_L}%mVNv4rEFwt0ee-)UnHEDY* zN#Bn&m&(sK;5LgCrjweQtX3jg>XIHK*Eu9tv?U+&YgeVT4?jqkt)_KH4M;c?nLBZw zc=f049wka5j@Md1kSj_4oPST{miF5ysF4?4jDa2KHQ7L!H9w^|&z?Ch5XO_dG(T8# zsg88*oLlhtDMzI5P2xIfq~;8`bi0_lOJngxaqUcc$bvY~3Zx!@^V+G1D}qOxG-EF-+IoWp4fGkTp-VdR)%aE_1o& ziO?Xi4{mX%@a4Z0-(L6BhE%p)n~R7Vn9FbqFH%ikA#XP_o12%1bFax5eRx>1ysg}> zy*Up?rzV6M?L1^5Y=0hAvxJLtgYDXt9C36@d9iw44xrn3=yjF^NJm`ce?_zv)B{rOKU3P z)N4?$C@QEbC^u9!)K?S$`~3feq<&T@?dV^QSpO>qY5$Lq{C^q7PF5b5TNgm>+~ zd|YGn7x;Z?bflN-Jl(lJo1WX_w^{p!LDe}FnGM1{uKWdPOrUBaAZD`1R_x_l zo+nWZ1?_^ACRf0*(ao3ry>ECe+UfGnHP7g6_PHFH>G5IVUa{gj;n%UmKiXXj|_fJI`J}fmCBU_$KmN!6fSiH>FJew zZvJMBnOwqDR7HV552RSwwe&}oI)9%=;N=}XsNFu)6V0Y~@+3o-(VZtyW`?_$=$i~A z2UL)i2Ix>7Eg;ziP?hK>ui_6&5q&H}``^LwVR>-Jvwpux)JSo+-SFL{M1CLn0HcH+ zhjeMOG|>N8HRWxPuA9yRDwz0S8!IuI^xAf!8H$$}hB&a+^)Gn3Y#JBYA+E2%Xz9Ds zRBg6*#?F2}+nBU?9KWEucO+W#ERXa60AolZDAXlMF8eT!CLa``u zw1poi*$`enb6n6W7fBg|da$ z%T(ExpI1QTYo^v!Ur`GgLjXfVOz+W3`5mqumL`CiF3~hPj`vVSU8wCI+360`ak=Q% zPyP*#KM`R6w}4u&de560K~A{LTKDR@-l#4)-oWSY_67Nc5hqk*$pKe+woqH{W1}V3 zDb^SvT87TKyrpJU+^grF8xi7c81jQq!MY`w75f><5Rx#i>?mDS29*2!^~diKovF!# zbNG7N{6#Ie%L++YGtM9@h+Zr1L>^Ir;bYV0yZt}cC0^n*DXWk2x})4zt!m7ggM;YP z(NK<0-i&?av7njex5In~_SRE~|r1LK_ykb2R` zH`gD$Xo4OeHitI5#rAId1FK4eGd~9(A}}F*df)dCH?hqS%D)`2%fwCbC33HlzoJJS z(3mH%N_Y0bR8Y}QkPHObSkN&@|hw!o*+*Oxl)FgC_nZhF{Dx3QSgz`pnBQJk z8Yvh3DQMLZE~=+18i4&_l>TQWMo~QO#3!cr)8C-9xz%q}M(EX!UaRfy{x@>Kfh_q{ zKL(;r6r&*IgX21eAWu)HyX^WvQnv{YzwY9S)^cyw1*g6d&Ws>v$$8p0rCa}~;=C6}Sx-g<@dWnzM^yNjw5V)%X z;Oef6XSQY>WUK&+0>VUw#ZVD(VI#zA;RPhbuFlhS5*x?Z*DDtFR0?4V2xiWf#K3k3 zdWd?4)Ibjn3XpsUe7qz=LBseyaqW<>)*!ju>!b#Dou3m=D86=|f8hL_^R+SG<6>o|O{C90{a%op)~!V6MaPcj zCBEF`JpD_;3t>uL9|7;dEOhlyjxjYgR|C=u`O>NZ{$qSYt|H4Sjwx0!YPFkGWlI%Oidp-5 z33upu8;L;voSMrFCxmm=IysZR5WTq4W>ke)S9Y6LVcABRStfVQ+{T$WZ5E1kHpx^$ zAOw5N)RwN=xbkL3S8OG+bYNp6g)*ZfdkfSDD_hD+nq2E>HyVNLYdYJh&S4B1C#w=Y{= z`C~Y;E}5)6F%{w~3}rtuI8jo9D_Z5K$~ey3!+?2NI%o|012~=VnmLPLmMWJK zrvR#1N+uSykG^#*I=m`SEXdZj&RGdMySRrBTihhnL(L5N|-2sPl`3h z(U~WjZbs0{v30W-3P!)ipNcz zaP+dIn{Hc-hh16I87j>iEO(U3?8TYyCqZtCgWRGs@MhxL_-j+jZDU zPR;_<5ty=8vr;;1GS&UzqOKanVl-!2dHFFduJW6z!YcDSY`V}P!2ly==k%_Rhr&j} zs`1r}EGw#mSW=qMnlNrSU+-3;)5zhJ_0qZ^ccKNp4dQQ=l7UR4j^5#BrLmBkcUdl3 zHHs4aihIZY8A=u1X^rEis$$DzmLx=_7Wvvn5U=^4C!l)7@#{`()g0xZHRu6UF-*B( zG}&XJ>oKB@XwJdAtj%p)m?ddiPrfE-qfSOoHDF#AI|CmVc~UhL=s=vmKX*#mY=BUs z)UD=iwpA3v%peis%umXDC0%;PytSoeIrQ+WWrJsxPL-Coy^yNhg}0ymI@CHbk*Al* zJKk_?dsiY)fioho&nrKq)DEqsJY9s*E;ZuiG83{=WYaBjHi}c5z+(Y`K@eCPV!e~O zomHhl?X0c2RLpl5dvmq}&coU-FYYOV#ZaDJpy*35yBX_PzI;w*n8Lf+A5eA5UfjYt z9PLXux6(Ok@*C0xu|sXs-v`fNpGrGou`UT+nV~js3iU2!kAMLTLdx@19Q(Z19E0?tSm^*7*GbTr$Hp+wxK&V?4z4N<#JmnmZPEy;OU?v*_MSO6g3a%yNXz zIY%K+f%E1i)6Uvq*lRs3+*6@DZ17KHxyo%vrJ{*|-l$OrPqylGE4lPJHr1*sljPaY zc2ZU%=d&`hOpm|=pXEmuI5$E@d)7wra&40Kz8Mn zU1)CDgKs;|cKgI%-Bs65t0P*b=n|g9vBeMMv-zz8>ajf(te$ z21lyD3=98^8EV^-pS@ETN9Y#SX;^~9AX~9cYAZx?q~=K$FsJfQ$$?@KIr1p~)kf`q z)dGYT@E8o;GhX#;R;s5R0=P@(2VfAbrW*NX%l}t((vLaG@N%}DP?mY|eSzF$vCnw= z2os_kT>!eM!5iMbH1j9OcTWoh42#TcO)d*=QnKM052=-)yg)DIhB!=T?jY5nXGD^M zbb@J*WXg|rrvYtX_N)$kH|d`tnK51R7QI|QOQat+2PcejQ$312pD^w99v_=upx>Ha z%)q~5n!lopyU64Gr)%13V3OzjKJ|%)NS^?=+Mak|@AP6IX?eM(2Gkz3Q8aXO{1G@{ z0tHkn0ts!c@)c#bNS)YNdoU;^xc}D~myv19Oe5Y22#3a^_{~KvguFQzIN0^Ydo#RJ z0UOlP8fvH{a7PM%?b2}i_mJ3bxi-vhHm>$P-CNg-uG!!VWroJE={wF3g>Gw3=V{JSrd;xYT?IM+@# zjywl1t;&Y_CASC<2y^ecZ}cDd|14GLkAtCbK!AWI|EAv<@aknSV#z@L?+mMjRv(6@)AT90pD|(WIMU+uYt(ug!X!KfM=xi`BsZxAjWF z7j!$J1Pw-{<1%&n>~m~xHa&6O&F>4`gG_^zM#IHgd7$E|{=SnrXccS;HcyxCrn&Bz z{Ty=cvqhZ`_k%D_bJed+b@sf??34na`Ug|g-s6!9?=xtzS0qcmjFS;4||0<+QYq#@om^$-(dh8aB%*vBPp`Ym(DFItOQi$F@DS9tOtn5sN>Q$r zFl5mt?Qlbr>7o$;VkyPLL3`HMVw`B#KG?^Sc5{auAi*T}rvIGIZG!i61#c$I(eR`V zIz_nxSz)?s^fgWwWQsVA2gKb)y&8*yVcjAw&ZZ%)wo03C4NyMoUgfX;%hBYr!IVwi zXh~<0n4#f(Jx7!I&TZ3#jP2pVo8k6N*K8e)+hmuw-sU{#`mXbmvrC(0Nw%xGSM8%W zd4nm3+U1rKBw{)gmT+epGT6a@7;z9qM?md}9Ls?5MDUx?h;R;kzfzzGEe!|_g?}#) zw)1$e>(@o7NhZz-fuKz<5fA(Y4^94OfDQCI4i7LR!YL2z;VwMv@h;uQc)t%OCvcwu z>QIb9ST9E)1S}b*40!nj!N4yjNahiSFb419n^Au}aLo_|VhUJff2W9EmOx){W>_<< z3C0=BgRlSAX-f8U9S{Di2#2;^AQG{GV-yO7(YV zZE>_OH6 zxFVkM0>s}4Tc{4u!FjKFX$&BFu%-qt5L_Qqu>1`D)e|ni?5j!iS5p!J0?cde@0VAr zo6cR%Q&+j)KIiN}c)inLYja25OvY2CYIjO<^3#)RqYaicRw)IVqd8eQt}ezdmV))u zT!N7PClzgf$m+`&vvD#q7Ux7hfomJA9*#=jtmUSdz+tD@7i`=l4aB9bJ`1Oo$iqvH zWXQ|0NAFupE9|B%(yzcel@0vH8j2I^Gnh?F+YHH7`x$T-XJwZloL4Kjk{3BDhIX4m z=-@G%3VvEfi&HksIl@V|r;*0-P$SG^2}15Nc>h8Ev!G5&fei+!a=3gtS=L0$u$s`8 zI8^wsWi)=ZQPW0kTCWSm^XM1nTX8vX1ar}zXeTRP*8E4yK2K@^>Vi@tOE*jY4D6iD z(k#;Gp#gUQUWo>VN|vS>P<<#PD}V3 zJ0N>`SP5KIQKa`{fa%DBwM{0UhEEt1w3(#4@tO{ zrBAAp)l*fdCMf{)dH;t;8VE3&2Ru~6*7zM*L{q+O)fQdNW+pMNwN`z=%WnY#mpyNM zFZQq~f^cC%oB_kNxpSHHAs0l15C;*r1y9^hP30mxe0WAO9KHSh5}CJR^np+zfQno4 zMd2_@eTI}PKFsb*o25r2xT8`J`zneEyE*#gO8tSwW=atq*etaId#j?m;Dd*nUP1DG z3a56%oiT8pp0lqY>q?>Fc-d@bjh`#cBJ(e1!mJ@p{}iumv;j_k0!vn=HC~#T4W%u+ zEfqTEpnF5uy?$U2>`UL~Q}><0u!BL1*(Of$X)@(DS(q3o6|a>ru@k5(S7Vv%|LTbTno%Uh+MJU%^CoDHac&f8y%SPL0&5jzZOzJWwRjj8D(Z@jdC&}8% z);Kz%Fq-X9XUAcB!>%NBu^IZ)hJ|dw=%59Yg2B7!$fsM7LFP=SPOGQ|nQ7BbM>|ALpO%rrz;^l@E$`h&I?|H|C;q=+SZ7jW*<}65Md+SQ*5{yVazY{^nA30^9kG zaT|>Iv=eEakSRkg=*=0Fx