diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7237e3e2..aa9812d5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,7 @@ plugins { android { namespace = "com.arflix.tv" - compileSdk = 35 + compileSdk = 36 flavorDimensions += "distribution" @@ -56,10 +56,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") } } @@ -140,9 +142,7 @@ android { isCoreLibraryDesugaringEnabled = true } - kotlinOptions { - jvmTarget = "17" - } + buildFeatures { compose = true @@ -154,8 +154,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 @@ -240,8 +239,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) @@ -536,3 +535,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/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 00000000..565df23b Binary files /dev/null and b/app/libs/quickjs-kt-android-1.0.5-nuvio.aar differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a7c1bf9a..cdfc2550 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -132,5 +132,3 @@ - - diff --git a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt index 2d598ba1..14788c84 100644 --- a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt +++ b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt @@ -285,7 +285,3 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact } } } - - - - 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..dde300d6 --- /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/api/SupabaseClient.kt b/app/src/main/kotlin/com/arflix/tv/data/api/SupabaseClient.kt index e3e1d21a..55ad0c74 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/SupabaseClient.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/SupabaseClient.kt @@ -36,7 +36,7 @@ interface SupabaseApi { @Query("limit") limit: Int = 50, @Query("offset") offset: Int? = null ): List - + @POST("rest/v1/watch_history") suspend fun upsertWatchHistory( @Header("Authorization") auth: String, @@ -44,7 +44,7 @@ interface SupabaseApi { @Header("Prefer") prefer: String = "resolution=merge-duplicates", @Body item: WatchHistoryRecord ) - + @GET("rest/v1/watch_history") suspend fun getWatchHistoryItem( @Header("Authorization") auth: String, @@ -73,7 +73,7 @@ interface SupabaseApi { @Query("episode") episode: String? = null, @Query("source") source: String? = null ) - + @retrofit2.http.HTTP(method = "DELETE", path = "rest/v1/watch_history", hasBody = false) suspend fun deleteWatchHistoryByIds( @Header("Authorization") auth: String, @@ -82,7 +82,7 @@ interface SupabaseApi { ) // ========== User Profiles ========== - + @GET("rest/v1/profiles") suspend fun getProfile( @Header("Authorization") auth: String, @@ -90,7 +90,7 @@ interface SupabaseApi { @Query("id") userId: String, @Query("select") select: String = "*" ): List - + @PATCH("rest/v1/profiles") suspend fun updateProfile( @Header("Authorization") auth: String, @@ -98,7 +98,7 @@ interface SupabaseApi { @Query("id") userId: String, @Body profile: UserProfileUpdate ) - + // ========== Watchlist ========== @GET("rest/v1/watchlist") @@ -130,7 +130,7 @@ interface SupabaseApi { ) // ========== Watched Status (from Trakt sync) ========== - + @GET("rest/v1/watched_movies") suspend fun getWatchedMovies( @Header("Authorization") auth: String, @@ -142,7 +142,7 @@ interface SupabaseApi { @Query("offset") offset: Int = 0, @Query("limit") limit: Int = 1000 ): List - + @GET("rest/v1/watched_episodes") suspend fun getWatchedEpisodes( @Header("Authorization") auth: String, @@ -165,7 +165,7 @@ interface SupabaseApi { @Query("tmdb_id") tmdbId: String, @Query("select") select: String = "user_id,profile_id,tmdb_id,show_trakt_id,season,episode,trakt_episode_id,tmdb_episode_id,watched_at,updated_at,source" ): List - + @POST("rest/v1/watched_movies") suspend fun markMovieWatched( @Header("Authorization") auth: String, @@ -173,7 +173,7 @@ interface SupabaseApi { @Header("Prefer") prefer: String = "resolution=merge-duplicates", @Body record: WatchedMovieRecord ) - + @POST("rest/v1/watched_episodes") suspend fun markEpisodeWatched( @Header("Authorization") auth: String, @@ -406,5 +406,3 @@ data class SyncStateRecord( @SerializedName("last_error") val lastError: String? = null, @SerializedName("updated_at") val updatedAt: String? = null ) - - diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt index 5c26a2eb..2154e154 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt @@ -9,7 +9,7 @@ import retrofit2.http.Query * TMDB API interface */ interface TmdbApi { - + @GET("trending/movie/day") suspend fun getTrendingMovies( @Query("api_key") apiKey: String, @@ -23,7 +23,7 @@ interface TmdbApi { @Query("language") language: String? = null, @Query("page") page: Int = 1 ): TmdbListResponse - + @GET("discover/tv") suspend fun discoverTv( @Query("api_key") apiKey: String, @@ -59,21 +59,21 @@ interface TmdbApi { @Query("language") language: String? = null, @Query("page") page: Int = 1 ): TmdbListResponse - + @GET("movie/{movie_id}") suspend fun getMovieDetails( @Path("movie_id") movieId: Int, @Query("api_key") apiKey: String, @Query("language") language: String? = null ): TmdbMovieDetails - + @GET("tv/{tv_id}") suspend fun getTvDetails( @Path("tv_id") tvId: Int, @Query("api_key") apiKey: String, @Query("language") language: String? = null ): TmdbTvDetails - + @GET("tv/{tv_id}/season/{season_number}") suspend fun getTvSeason( @Path("tv_id") tvId: Int, @@ -89,7 +89,7 @@ interface TmdbApi { @Path("episode_number") episodeNumber: Int, @Query("api_key") apiKey: String ): TmdbExternalIds - + @GET("{media_type}/{id}/credits") suspend fun getCredits( @Path("media_type") mediaType: String, @@ -97,7 +97,7 @@ interface TmdbApi { @Query("api_key") apiKey: String, @Query("language") language: String? = null ): TmdbCreditsResponse - + @GET("{media_type}/{id}/similar") suspend fun getSimilar( @Path("media_type") mediaType: String, @@ -120,7 +120,7 @@ interface TmdbApi { @Path("id") id: Int, @Query("api_key") apiKey: String ): TmdbImagesResponse - + @GET("{media_type}/{id}/videos") suspend fun getVideos( @Path("media_type") mediaType: String, @@ -128,7 +128,7 @@ interface TmdbApi { @Query("api_key") apiKey: String, @Query("language") language: String? = null ): TmdbVideosResponse - + @GET("person/{person_id}") suspend fun getPersonDetails( @Path("person_id") personId: Int, @@ -160,7 +160,7 @@ interface TmdbApi { @Path("tv_id") tvId: Int, @Query("api_key") apiKey: String ): TmdbWatchProvidersResponse - + @GET("search/multi") suspend fun searchMulti( @Query("api_key") apiKey: String, diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt index 2d900612..4afb3f56 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt @@ -14,27 +14,27 @@ import retrofit2.Response * Trakt.tv API interface */ interface TraktApi { - + // ========== Authentication ========== - + @POST("oauth/device/code") @retrofit2.http.Headers("Content-Type: application/json") suspend fun getDeviceCode( @Body request: DeviceCodeRequest ): TraktDeviceCode - + @POST("oauth/device/token") @retrofit2.http.Headers("Content-Type: application/json") suspend fun pollToken( @Body request: TokenPollRequest ): TraktToken - + @POST("oauth/token") @retrofit2.http.Headers("Content-Type: application/json") suspend fun refreshToken( @Body request: RefreshTokenRequest ): TraktToken - + // ========== Sync ========== @GET("sync/last_activities") @@ -52,7 +52,7 @@ interface TraktApi { @Header("trakt-api-key") clientId: String, @Header("trakt-api-version") version: String = "2" ): List - + @GET("sync/watched/shows") @retrofit2.http.Headers("Content-Type: application/json") suspend fun getWatchedShows( @@ -60,7 +60,7 @@ interface TraktApi { @Header("trakt-api-key") clientId: String, @Header("trakt-api-version") version: String = "2" ): List - + @GET("sync/playback") @retrofit2.http.Headers("Content-Type: application/json") suspend fun getPlaybackProgress( @@ -71,7 +71,7 @@ interface TraktApi { @Query("page") page: Int? = null, @Query("limit") limit: Int? = null ): List - + @DELETE("sync/playback/{id}") suspend fun removePlaybackItem( @Header("Authorization") auth: String, @@ -79,7 +79,7 @@ interface TraktApi { @Header("trakt-api-version") version: String = "2", @Path("id") id: Long ) - + @POST("sync/history") @retrofit2.http.Headers("Content-Type: application/json") suspend fun addToHistory( @@ -88,7 +88,7 @@ interface TraktApi { @Header("trakt-api-version") version: String = "2", @Body body: TraktHistoryBody ): TraktSyncResponse - + @POST("sync/history/remove") @retrofit2.http.Headers("Content-Type: application/json") suspend fun removeFromHistory( @@ -97,7 +97,7 @@ interface TraktApi { @Header("trakt-api-version") version: String = "2", @Body body: TraktHistoryBody ): TraktSyncResponse - + @POST("scrobble/start") @retrofit2.http.Headers("Content-Type: application/json") suspend fun scrobbleStart( @@ -300,7 +300,7 @@ interface TraktApi { ): TraktSyncResponse // ========== Watchlist ========== - + @GET("users/me/watchlist") suspend fun getWatchlist( @Header("Authorization") auth: String, @@ -331,7 +331,7 @@ interface TraktApi { @Query("page") page: Int, @Query("limit") limit: Int ): Response> - + @POST("sync/watchlist") @retrofit2.http.Headers("Content-Type: application/json") suspend fun addToWatchlist( @@ -340,7 +340,7 @@ interface TraktApi { @Header("trakt-api-version") version: String = "2", @Body body: TraktWatchlistBody ): TraktSyncResponse - + @POST("sync/watchlist/remove") @retrofit2.http.Headers("Content-Type: application/json") suspend fun removeFromWatchlist( @@ -349,7 +349,7 @@ interface TraktApi { @Header("trakt-api-version") version: String = "2", @Body body: TraktWatchlistBody ): TraktSyncResponse - + // ========== Up Next ========== @GET("shows/{id}/progress/watched") @@ -362,7 +362,7 @@ interface TraktApi { @Query("specials") specials: String = "false", @Query("count_specials") countSpecials: String = "false" ): TraktShowProgress - + // ========== Hidden Items ========== @GET("users/hidden/progress_watched") @@ -919,5 +919,3 @@ data class TraktBulkEpisodeItem( val number: Int, @SerializedName("watched_at") val watchedAt: String? = null ) - - 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..fd4fbcfa --- /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/data/model/Models.kt b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt index d4edfa96..bb0f8ada 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt @@ -300,7 +300,7 @@ data class AddonStreamResult( * These filters apply to ALL profiles on this device (e.g., 1080p TV always excludes 4K) * regardless of which profile is logged in. This ensures device capabilities limit quality, * not user profiles. - * + * * Example: 1080p TV with regex "4K|2160p" excludes 4K streams for all users */ @Immutable diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt index 24e93dec..0523fdfe 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt @@ -172,15 +172,15 @@ class AuthRepository @Inject constructor( install(Postgrest) } } - + // Auth state private val _authState = MutableStateFlow(AuthState.Loading) val authState: StateFlow = _authState.asStateFlow() - + // User profile private val _userProfile = MutableStateFlow(null) val userProfile: StateFlow = _userProfile.asStateFlow() - + /** * Check if user is logged in on app start * Note: Supabase SDK requires main thread for initialization (lifecycle observers) @@ -267,7 +267,7 @@ class AuthRepository @Inject constructor( _authState.value = AuthState.NotAuthenticated } } - + /** * Sign in with email and password */ @@ -561,7 +561,7 @@ class AuthRepository @Inject constructor( _userProfile.value = null _authState.value = AuthState.NotAuthenticated } - + /** * Load user profile from Supabase */ @@ -591,7 +591,7 @@ class AuthRepository @Inject constructor( null } } - + /** * Create default profile for new user */ @@ -629,7 +629,7 @@ class AuthRepository @Inject constructor( else -> fallback } } - + /** * Update user profile */ @@ -640,28 +640,28 @@ class AuthRepository @Inject constructor( .update(profile) { filter { eq("id", profile.id) } } - + _userProfile.value = profile Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } - + /** * Get Trakt token from profile */ fun getTraktAccessToken(): String? { return _userProfile.value?.trakt_token?.get("access_token")?.jsonPrimitive?.content } - + /** * Check if user has Trakt linked */ fun isTraktLinked(): Boolean { return _userProfile.value?.trakt_token != null } - + /** * Get current user ID */ @@ -1222,5 +1222,3 @@ class AuthRepository @Inject constructor( } } } - - diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index 2a5a217f..fe95431e 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -2889,7 +2889,7 @@ class MediaRepository @Inject constructor( seasonEpisodesCache[cacheKey] = CacheEntry(episodes, System.currentTimeMillis()) return episodes } - + /** * Get cast members (cached) */ @@ -2995,7 +2995,7 @@ class MediaRepository @Inject constructor( val cacheKey = "tv_${tvId}_season_$seasonNumber" return getFromCache(seasonEpisodesCache, cacheKey) } - + /** * Get trailer key (YouTube) */ @@ -3025,7 +3025,7 @@ class MediaRepository @Inject constructor( val person = tmdbApi.getPersonDetails(personId, apiKey, language = contentLanguage) return person.toPersonDetails() } - + /** * Search media */ @@ -3591,10 +3591,10 @@ private fun TmdbMediaItem.toMediaItem(defaultType: MediaType): MediaItem { "movie" -> MediaType.MOVIE else -> defaultType } - + val dateStr = releaseDate ?: firstAirDate ?: "" val year = dateStr.take(4) - + return MediaItem( id = id, title = title ?: name ?: "Unknown", @@ -3621,7 +3621,7 @@ private fun TmdbMovieDetails.toMediaItem(): MediaItem { val hours = (runtime ?: 0) / 60 val minutes = (runtime ?: 0) % 60 val duration = if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" - + return MediaItem( id = id, title = title, @@ -3656,7 +3656,7 @@ private fun TmdbTvDetails.toMediaItem(): MediaItem { .count() .takeIf { it > 0 } ?: numberOfSeasons.coerceAtLeast(1) - + return MediaItem( id = id, title = name, @@ -3717,12 +3717,12 @@ private fun TmdbPersonDetails.toPersonDetails(): PersonDetails { ?.filter { it.posterPath != null && (it.mediaType == "movie" || it.mediaType == "tv") } ?.sortedByDescending { it.voteCount } ?.take(20) - ?.map { + ?.map { it.toMediaItem( if (it.mediaType == "tv") MediaType.TV else MediaType.MOVIE ) } ?: emptyList() - + return PersonDetails( id = id, name = name, diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/SkipIntroRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/SkipIntroRepository.kt index da107903..d836a560 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/SkipIntroRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/SkipIntroRepository.kt @@ -131,4 +131,3 @@ class SkipIntroRepository @Inject constructor( private const val NO_MAL_ID = "__none__" } } - diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 96b240af..2836fef5 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -63,7 +63,7 @@ import javax.inject.Singleton private val Context.streamDataStore: DataStore by preferencesDataStore(name = "stream_prefs") /** - * Callback for streaming results as they arrive - + * Callback for streaming results as they arrive - */ typealias StreamCallback = (streams: List?, addonId: String, addonName: String, error: Exception?) -> Unit @@ -389,7 +389,7 @@ class StreamRepository @Inject constructor( } /** - * Add a custom Stremio addon from URL - + * Add a custom Stremio addon from URL - * Fetches manifest and stores addon info */ suspend fun addCustomAddon(url: String, customName: String? = null): Result = withContext(Dispatchers.IO) { @@ -816,7 +816,7 @@ class StreamRepository @Inject constructor( } /** - * Get transport URL (base URL without manifest.json) - + * Get transport URL (base URL without manifest.json) - */ private fun getTransportUrl(url: String): String { var cleanUrl = normalizeAddonInputUrl(url) @@ -1003,7 +1003,7 @@ class StreamRepository @Inject constructor( // ========== Stream Resolution ========== /** - * Filter addons that support streaming for the given content type - + * Filter addons that support streaming for the given content type - * More lenient filtering to ensure custom addons work */ /** diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt index 65af07bd..fb88f753 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt @@ -1934,4 +1934,3 @@ sealed class SyncResult { data class Success(val moviesSynced: Int, val episodesSynced: Int) : SyncResult() data class Error(val message: String) : SyncResult() } - 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..109bb3b7 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -41,7 +41,7 @@ object AppModule { .build() .create(TmdbApi::class.java) } - + @Provides @Singleton fun provideTraktApi(okHttpClient: OkHttpClient): TraktApi { @@ -52,7 +52,7 @@ object AppModule { .build() .create(TraktApi::class.java) } - + @Provides @Singleton fun provideSupabaseApi(okHttpClient: OkHttpClient): SupabaseApi { @@ -68,7 +68,7 @@ object AppModule { .build() .create(SupabaseApi::class.java) } - + @Provides @Singleton fun provideStreamApi(okHttpClient: OkHttpClient): StreamApi { @@ -150,4 +150,11 @@ 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..ad4b3fe5 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/domain/model/Plugin.kt @@ -0,0 +1,147 @@ +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/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index 572eab15..c74a37bb 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -57,7 +57,7 @@ sealed class Screen(val route: String) { object Settings : Screen("settings") object TelegramSettings : Screen("telegram_settings") object ProfileSelection : Screen("profile_selection") - + object Details : Screen("details/{mediaType}/{mediaId}?initialSeason={initialSeason}&initialEpisode={initialEpisode}") { fun createRoute( mediaType: MediaType, @@ -72,7 +72,7 @@ sealed class Screen(val route: String) { return if (params.isNotEmpty()) "$base?${params.joinToString("&")}" else base } } - + object Player : Screen("player/{mediaType}/{mediaId}?seasonNumber={seasonNumber}&episodeNumber={episodeNumber}&imdbId={imdbId}&streamUrl={streamUrl}&preferredAddonId={preferredAddonId}&preferredSourceName={preferredSourceName}&preferredBingeGroup={preferredBingeGroup}&startPositionMs={startPositionMs}") { fun createRoute( mediaType: MediaType, @@ -160,7 +160,7 @@ fun AppNavigation( } ) } - + // Home screen composable(Screen.Home.route) { HomeScreen( @@ -196,7 +196,7 @@ fun AppNavigation( onExitApp = onExitApp ) } - + // Search screen composable(Screen.Search.route) { SearchScreen( @@ -420,18 +420,18 @@ fun AppNavigation( onBack = { navController.popBackStack() } ) } - + // Player screen composable( route = Screen.Player.route, arguments = listOf( navArgument("mediaType") { type = NavType.StringType }, navArgument("mediaId") { type = NavType.IntType }, - navArgument("seasonNumber") { + navArgument("seasonNumber") { type = NavType.IntType defaultValue = -1 }, - navArgument("episodeNumber") { + navArgument("episodeNumber") { type = NavType.IntType defaultValue = -1 }, @@ -439,7 +439,7 @@ fun AppNavigation( type = NavType.StringType defaultValue = "" }, - navArgument("streamUrl") { + navArgument("streamUrl") { type = NavType.StringType defaultValue = "" }, @@ -472,7 +472,7 @@ fun AppNavigation( val preferredBingeGroup = backStackEntry.arguments?.getString("preferredBingeGroup")?.takeIf { it.isNotBlank() } val startPositionMs = backStackEntry.arguments?.getLong("startPositionMs")?.takeIf { it >= 0L } val mediaType = if (mediaTypeStr == "tv") MediaType.TV else MediaType.MOVIE - + PlayerScreen( mediaType = mediaType, mediaId = mediaId, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AudioTrackSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AudioTrackSelector.kt index bd51c6c8..a3190a38 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AudioTrackSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AudioTrackSelector.kt @@ -72,7 +72,7 @@ fun AudioTrackSelector( onClose: () -> Unit ) { var focusedIndex by remember(isVisible) { mutableIntStateOf(0) } - + AnimatedVisibility( visible = isVisible, enter = fadeIn(), @@ -133,7 +133,7 @@ fun AudioTrackSelector( color = TextPrimary ) } - + // Track list if (audioTracks.isEmpty()) { Box( @@ -164,9 +164,9 @@ fun AudioTrackSelector( } } } - + Spacer(modifier = Modifier.height(16.dp)) - + // Help text Text( text = stringResource(R.string.press_back_to_close), @@ -214,11 +214,11 @@ private fun AudioTrackItem( style = ArflixTypography.body, color = if (isFocused) Color.White else if (isSelected) TextPrimary else TextSecondary ) - + // Show codec/channel info if available val metadata = buildList { track.codec?.let { add(it.uppercase()) } - track.channels?.let { + track.channels?.let { add(when (it) { 1 -> "Mono" 2 -> "Stereo" @@ -229,7 +229,7 @@ private fun AudioTrackItem( } if (track.isDefault) add("Default") } - + if (metadata.isNotEmpty()) { Text( text = metadata.joinToString(" • "), @@ -238,7 +238,7 @@ private fun AudioTrackItem( ) } } - + if (isSelected) { Icon( imageVector = Icons.Default.Check, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/CardLayoutMode.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/CardLayoutMode.kt index a7abfc95..0b1c7402 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/CardLayoutMode.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/CardLayoutMode.kt @@ -211,7 +211,7 @@ fun CatalogueRowLayoutToggleButton( visualFocused -> Color.Black else -> Color.White.copy(alpha = 0.7f) } - + Box( modifier = Modifier .fillMaxSize() @@ -229,15 +229,15 @@ fun CatalogueRowLayoutToggleButton( val path = androidx.compose.ui.graphics.Path() val w = size.width val h = size.height - + val rectW = if (isPoster) w * 0.60f else w val rectH = if (isPoster) h else h * 0.60f - + val left = (w - rectW) / 2f val top = (h - rectH) / 2f val right = left + rectW val bottom = top + rectH - + path.moveTo(left, top) if (isPoster) { val curve = rectW * 0.20f @@ -253,7 +253,7 @@ fun CatalogueRowLayoutToggleButton( path.lineTo(left, top) } path.close() - + drawPath( path = path, color = canvasColor, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt index 89ec6a67..79affff9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt @@ -169,7 +169,7 @@ fun ContextMenu( style = ArflixTypography.sectionTitle, color = TextPrimary ) - + // Subtitle if (subtitle != null) { Spacer(modifier = Modifier.height(8.dp)) @@ -179,9 +179,9 @@ fun ContextMenu( color = TextSecondary ) } - + Spacer(modifier = Modifier.height(16.dp)) - + // Divider Box( modifier = Modifier @@ -189,9 +189,9 @@ fun ContextMenu( .height(1.dp) .background(Color.White.copy(alpha = 0.1f)) ) - + Spacer(modifier = Modifier.height(12.dp)) - + // Actions Column( verticalArrangement = Arrangement.spacedBy(8.dp) @@ -203,9 +203,9 @@ fun ContextMenu( ) } } - + Spacer(modifier = Modifier.height(14.dp)) - + // Close hint Row( verticalAlignment = Alignment.CenterVertically, @@ -362,7 +362,7 @@ private fun ContextMenuItem( ) { val bgColor = if (isFocused) Color.White.copy(alpha = 0.1f) else Color.Transparent val borderColor = if (isFocused) Pink else Color.Transparent - + Row( modifier = Modifier .fillMaxWidth() @@ -381,17 +381,17 @@ private fun ContextMenuItem( tint = if (isFocused) Pink else action.color, modifier = Modifier.size(24.dp) ) - + Spacer(modifier = Modifier.width(16.dp)) - + Text( text = action.label, style = ArflixTypography.body, color = if (isFocused) TextPrimary else action.color ) - + Spacer(modifier = Modifier.weight(1f)) - + if (isFocused) { Box( modifier = Modifier @@ -429,7 +429,7 @@ fun EpisodeContextMenu( ContextActions.selectSource, if (isWatched) ContextActions.markUnwatched else ContextActions.markWatched ) - + ContextMenu( isVisible = isVisible, title = episodeName, @@ -498,7 +498,7 @@ fun MediaContextMenu( if (isInWatchlist) ContextActions.removeWatchlist else ContextActions.addWatchlist, ContextActions.viewDetails ) - + ContextMenu( isVisible = isVisible, title = title, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt index f6c6e340..fb6aa806 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt @@ -308,4 +308,3 @@ fun ContinueWatchingCardCompact( } } } - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/LoadingIndicator.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/LoadingIndicator.kt index 4298598f..ee25a750 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/LoadingIndicator.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/LoadingIndicator.kt @@ -31,7 +31,7 @@ fun LoadingIndicator( strokeWidth: Dp = 4.dp ) { val infiniteTransition = rememberInfiniteTransition(label = "loading") - + val rotation by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, @@ -41,13 +41,13 @@ fun LoadingIndicator( ), label = "rotation" ) - + Canvas( modifier = modifier.size(size) ) { val strokeWidthPx = strokeWidth.toPx() val arcSize = Size(this.size.width - strokeWidthPx, this.size.height - strokeWidthPx) - + // Background arc drawArc( color = color.copy(alpha = 0.2f), @@ -58,7 +58,7 @@ fun LoadingIndicator( size = arcSize, style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round) ) - + // Animated arc drawArc( color = color, @@ -82,7 +82,7 @@ fun PulsingLoadingIndicator( color: Color = Pink ) { val infiniteTransition = rememberInfiniteTransition(label = "pulsing") - + val scale by infiniteTransition.animateFloat( initialValue = 0.8f, targetValue = 1.2f, @@ -92,7 +92,7 @@ fun PulsingLoadingIndicator( ), label = "scale" ) - + val alpha by infiniteTransition.animateFloat( initialValue = 0.3f, targetValue = 1f, @@ -102,7 +102,7 @@ fun PulsingLoadingIndicator( ), label = "alpha" ) - + Canvas( modifier = modifier.size(size) ) { @@ -117,5 +117,3 @@ fun PulsingLoadingIndicator( ) } } - - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/NextEpisodeOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/NextEpisodeOverlay.kt index dcc3f836..b5be9f00 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/NextEpisodeOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/NextEpisodeOverlay.kt @@ -97,7 +97,7 @@ fun NextEpisodeOverlay( runCatching { overlayFocusRequester.requestFocus() } } } - + // Countdown timer LaunchedEffect(isVisible) { if (isVisible) { @@ -112,7 +112,7 @@ fun NextEpisodeOverlay( } } } - + AnimatedVisibility( visible = isVisible, enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), @@ -192,9 +192,9 @@ fun NextEpisodeOverlay( color = TextSecondary ) } - + Spacer(modifier = Modifier.height(16.dp)) - + // Episode preview Row( verticalAlignment = Alignment.CenterVertically @@ -228,7 +228,7 @@ fun NextEpisodeOverlay( ) } } - + // Progress bar at bottom Box( modifier = Modifier @@ -250,9 +250,9 @@ fun NextEpisodeOverlay( ) } } - + Spacer(modifier = Modifier.width(16.dp)) - + // Episode info Column(modifier = Modifier.weight(1f)) { Text( @@ -275,9 +275,9 @@ fun NextEpisodeOverlay( ) } } - + Spacer(modifier = Modifier.height(20.dp)) - + // Buttons Row( horizontalArrangement = Arrangement.spacedBy(12.dp) @@ -316,7 +316,7 @@ fun NextEpisodeOverlay( ) } } - + // Cancel button Box( modifier = Modifier diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt index f1f88d9a..46c09855 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt @@ -631,7 +631,7 @@ private fun HorizontalKnownForCard( } } } - + // Focus glow effect if (isFocused) { Box( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt index 3b8c2dee..dd958b69 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/SettingsRows.kt @@ -190,4 +190,4 @@ fun SettingsToggleRow( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt index 021c8c7e..6c69f01c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt @@ -312,5 +312,3 @@ private fun SidebarIcon( } } } - - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/SourceInfoOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/SourceInfoOverlay.kt index ac5e6aa8..8732fe5c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/SourceInfoOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/SourceInfoOverlay.kt @@ -89,9 +89,9 @@ fun SourceInfoOverlay( color = getQualityColor(source.quality) ) } - + Spacer(modifier = Modifier.height(4.dp)) - + // Source indicator Row( verticalAlignment = Alignment.CenterVertically, @@ -110,7 +110,7 @@ fun SourceInfoOverlay( color = TextSecondary ) } - + // Size info if (source.size.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) @@ -166,7 +166,7 @@ fun PlayerInfoBar( ) } } - + if (isLive) { Row( modifier = Modifier @@ -196,7 +196,7 @@ fun PlayerInfoBar( */ private fun getQualityColor(quality: String): Color { return when { - quality.contains("4K", ignoreCase = true) || + quality.contains("4K", ignoreCase = true) || quality.contains("2160p", ignoreCase = true) -> Color(0xFFFFD700) // Gold quality.contains("1080p", ignoreCase = true) -> Pink quality.contains("720p", ignoreCase = true) -> Color(0xFF3B82F6) // Blue diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index ae6cbc14..7c248c71 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -1438,4 +1438,3 @@ private fun qualityScore(quality: String): Int { else -> 0 } } - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Toast.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Toast.kt index 9f4f7013..1bb1017d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Toast.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Toast.kt @@ -56,7 +56,7 @@ fun Toast( onDismiss: () -> Unit = {} ) { var visible by remember(isVisible, message, type) { mutableStateOf(isVisible) } - + LaunchedEffect(isVisible, message, type) { if (isVisible) { visible = true @@ -65,7 +65,7 @@ fun Toast( onDismiss() } } - + Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index f7a32a6c..33b02e9b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -227,19 +227,19 @@ fun DetailsScreen( var similarIndex by remember { mutableIntStateOf(0) } var collectionIndex by remember { mutableIntStateOf(0) } var suppressSelectUntilMs by remember { mutableLongStateOf(0L) } - + // Sidebar state var isSidebarFocused by remember { mutableStateOf(false) } val hasProfile = currentProfile != null val maxSidebarIndex = topBarMaxIndex(hasProfile) var sidebarFocusIndex by remember { mutableIntStateOf(if (hasProfile) 2 else 1) } - + // Stream Selector state var showStreamSelector by remember { mutableStateOf(false) } var showTrailerPlayer by remember { mutableStateOf(false) } KeepScreenOn(active = showTrailerPlayer) var pendingAutoPlayRequest by remember { mutableStateOf(null) } - + // Episode Context Menu state var showEpisodeContextMenu by remember { mutableStateOf(false) } var contextMenuEpisode by remember { mutableStateOf(null) } @@ -515,7 +515,7 @@ fun DetailsScreen( if (showStreamSelector || showEpisodeContextMenu || showSeasonContextMenu || uiState.showPersonModal) { return@onPreviewKeyEvent false // Let the modal handle it } - + when (event.key) { Key.Back, Key.Escape -> { if (showTrailerPlayer) { showTrailerPlayer = false; true } @@ -890,7 +890,7 @@ fun DetailsScreen( profile = currentProfile ) } - + // Person Modal PersonModal( isVisible = uiState.showPersonModal, @@ -902,7 +902,7 @@ fun DetailsScreen( onNavigateToDetails(type, id) } ) - + // In-app Trailer Player (fullscreen overlay) if (showTrailerPlayer && uiState.trailerKey != null) { BackHandler { showTrailerPlayer = false } @@ -975,7 +975,7 @@ fun DetailsScreen( }, onClose = { showStreamSelector = false } ) - + // Episode Context Menu contextMenuEpisode?.let { episode -> EpisodeContextMenu( @@ -1275,13 +1275,13 @@ private fun DetailsContent( ) ) ) - + if (topBarAlpha > 0f) { Box( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .graphicsLayer { + .graphicsLayer { alpha = topBarAlpha translationY = mobileScrollState.value.toFloat() } @@ -1308,7 +1308,7 @@ private fun DetailsContent( val currentInitialY = coords.positionInWindow().y + mobileScrollState.value val pinnedY = statusBarsTop - with(density) { 12.dp.toPx() } val calculatedThreshold = currentInitialY - pinnedY - + // Update if uninitialized, or if the layout shifts significantly (e.g. metadata loaded) // The > 10f check prevents infinite recomposition loops and ignores 1-2px scroll jitter. if (stickyThreshold < 0f || kotlin.math.abs(calculatedThreshold - stickyThreshold) > 10f) { @@ -1319,11 +1319,11 @@ private fun DetailsContent( if (stickyThreshold >= 0f && mobileScrollState.value > stickyThreshold) { val overscroll = mobileScrollState.value - stickyThreshold translationY = overscroll - + // Smooth scale down to feel like a header val maxOverscroll = 200f val progress = (overscroll / maxOverscroll).coerceIn(0f, 1f) - val scale = 1f - (0.28f * progress) + val scale = 1f - (0.28f * progress) scaleX = scale scaleY = scale } @@ -3716,9 +3716,9 @@ private fun CastCard( } } } - + Spacer(modifier = Modifier.height(8.dp)) - + Text( text = member.name, style = ArvioSkin.typography.cardTitle, @@ -3726,7 +3726,7 @@ private fun CastCard( maxLines = 1, overflow = TextOverflow.Ellipsis ) - + if (member.character.isNotEmpty()) { Text( text = member.character, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index a1b639e7..e95b9ca7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -1072,7 +1072,7 @@ fun HomeScreen( ) } } // end if (!isMobile) backdrop - + Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = trailerOverlayAlpha.value }) { HomeInputLayer( categories = displayCategories, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index a2f71de9..2431e336 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -218,7 +218,7 @@ class HomeViewModel @Inject constructor( try { val prefs = context.settingsDataStore.data.first() val profileId = profileManager.getProfileIdSync().ifBlank { "default" } - + // 1. Check specific row layout mode val rowKey = "home:$catalogId" val normalizedRowKey = com.arflix.tv.ui.components.normalizeCatalogueRowLayoutKey(rowKey) @@ -229,21 +229,21 @@ class HomeViewModel @Inject constructor( if (rowValue != null) { return@withContext rowValue.trim().equals("Poster", ignoreCase = true) } - + // 2. Check profile global default card layout mode val profilePrefKey = stringPreferencesKey("profile_${profileId}_card_layout_mode") val profileValue = prefs[profilePrefKey] if (profileValue != null) { return@withContext profileValue.trim().equals("Poster", ignoreCase = true) } - + // 3. Check legacy global default card layout mode val legacyPrefKey = stringPreferencesKey("card_layout_mode") val legacyValue = prefs[legacyPrefKey] if (legacyValue != null) { return@withContext legacyValue.trim().equals("Poster", ignoreCase = true) } - + // Default fallback false } catch (_: Exception) { @@ -254,21 +254,21 @@ class HomeViewModel @Inject constructor( private suspend fun catalogInitialLimit(catalog: CatalogConfig): Int { if (isHardCappedTop10Catalog(catalog.id)) return TOP_10_ITEM_LIMIT - + if (isCatalogPosterMode(catalog.id)) { // Dynamic limit calculation for portrait (poster) catalogs val screenWidthDp = context.resources.configuration.screenWidthDp val posterWidth = if (isTvDevice) 119 else 124 val posterSpacing = if (isTvDevice) 14 else 10 val padding = 16 - + // Calculate how many items fit on the screen val fitCount = (screenWidthDp - padding) / (posterWidth + posterSpacing) - + // We want to load at least 12 items, or fitCount + 2 (comfort items), whichever is larger return maxOf(12, fitCount + 2) } - + return initialCategoryItemCap } @@ -279,10 +279,10 @@ class HomeViewModel @Inject constructor( val posterWidth = if (isTvDevice) 119 else 124 val posterSpacing = if (isTvDevice) 14 else 10 val padding = 16 - + // Calculate how many items fit on the screen val fitCount = (screenWidthDp - padding) / (posterWidth + posterSpacing) - + // We want to load at least 12 items, or fitCount + 2 (comfort items), whichever is larger return maxOf(12, fitCount + 2) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt index 4c1a9d6e..a3a84e1c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt @@ -32,10 +32,10 @@ class LoginViewModel @Inject constructor( private val cloudSyncRepository: CloudSyncRepository ) : ViewModel() { private var lastSignUpAttemptMs: Long = 0L - + private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() - + init { // Observe auth state viewModelScope.launch { @@ -44,7 +44,7 @@ class LoginViewModel @Inject constructor( } } } - + fun signIn(email: String, password: String) { val normalizedEmail = AuthEmailValidator.normalize(email) AuthEmailValidator.validate(normalizedEmail, rejectDisposable = false)?.let { message -> @@ -80,7 +80,7 @@ class LoginViewModel @Inject constructor( } } } - + fun signUp(email: String, password: String) { val normalizedEmail = AuthEmailValidator.normalize(email) AuthEmailValidator.validate(normalizedEmail)?.let { message -> @@ -161,5 +161,3 @@ class LoginViewModel @Inject constructor( _uiState.update { it.copy(isLoading = false, error = error) } } } - - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 585ecdd2..e4747fe1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -1051,7 +1051,7 @@ fun PlayerScreen( selectedAudioIndex = extractedAudioTracks.indexOf(matchingTrack) } } - + // Extract embedded subtitles val textTracks = mutableListOf() val subtitleByTrackId = latestUiState.subtitles.associateBy { subtitleTrackId(it) } 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..39e646fe --- /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..08c8df24 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginViewModel.kt @@ -0,0 +1,282 @@ +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/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index be68257a..a19c99ba 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -274,7 +274,7 @@ fun SearchScreen( // This prevents the "back to keyboard" issue when returning from details } } - + LaunchedEffect(isTouchDevice) { // FocusRequester can throw IllegalStateException if the target composable // hasn't been placed yet (e.g. zero-sized keyboard on cold start, or when 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 4e54f022..52dc1faa 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 @@ -386,6 +386,9 @@ fun SettingsScreen( add("stremio") add("catalogs") add("home_server") + if (BuildConfig.FEATURE_PLUGINS_ENABLED) { + add("plugins") + } add("appearance") add("network") } @@ -509,7 +512,7 @@ fun SettingsScreen( dnsProviderPickerIndex = if (targetIndex >= 0) targetIndex else dnsProviderPickerIndex.coerceIn(0, maxIndex) } } - + // Reset content scroll AND position cache when switching sections. LaunchedEffect(sectionIndex) { focusTracker.clear() @@ -662,6 +665,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) @@ -1188,7 +1192,7 @@ fun SettingsScreen( } Spacer(modifier = Modifier.height(12.dp)) - + Text( text = "ARVIO V${BuildConfig.VERSION_NAME}", style = ArflixTypography.caption, @@ -1482,6 +1486,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, @@ -1696,7 +1708,7 @@ fun SettingsScreen( ) } - + if (showCatalogInput) { CatalogDiscoveryModal( @@ -3329,15 +3341,17 @@ private fun MobileSettingsMainPage( item { MobileSettingsCategory(title = "CATEGORIES") { - val categories = listOf( - "Playback & Controls" to Icons.Default.PlayArrow, - "Audio & Subtitles" to Icons.Default.Speaker, - "Appearance" to Icons.Default.Palette, - "Plugins & Extensions" to Icons.Default.Extension, - "Catalogs" to Icons.Default.Widgets, - "TV" to Icons.Default.LiveTv, - "Home Server" to Icons.Default.Cloud - ) + val categories = buildList { + add("Playback & Controls" to Icons.Default.PlayArrow) + add("Audio & Subtitles" to Icons.Default.Speaker) + add("Appearance" to Icons.Default.Palette) + if (BuildConfig.FEATURE_PLUGINS_ENABLED) { + add("Plugins & Extensions" to Icons.Default.Extension) + } + add("Catalogs" to Icons.Default.Widgets) + add("TV" to Icons.Default.LiveTv) + add("Home Server" to Icons.Default.Cloud) + } categories.forEachIndexed { index, (name, icon) -> Column { Row( @@ -4102,7 +4116,7 @@ private fun SettingsSectionItem( else -> TextSecondary } val accentColor = resolveAccentColor(fallback = Pink) - + Row( modifier = Modifier .fillMaxWidth() @@ -7570,7 +7584,7 @@ private fun AccountRow( } } Spacer(modifier = Modifier.width(12.dp)) - + if (isConnected) { Box( modifier = Modifier @@ -7609,7 +7623,7 @@ private fun AccountRow( } } } - + // Show expiration date when connected if (isConnected && expirationText != null) { Spacer(modifier = Modifier.height(8.dp)) @@ -8840,7 +8854,7 @@ private fun IptvCategoriesSettings( modifier = Modifier.padding(bottom = 12.dp) ) } - + SettingsRow( icon = Icons.Default.Refresh, title = "Reset Order", @@ -8850,7 +8864,7 @@ private fun IptvCategoriesSettings( onClick = onReset, modifier = Modifier.settingsFocusSlot(0) ) - + Spacer(modifier = Modifier.height(16.dp)) if (isMobile) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt index 297c5e64..120bcb5d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt @@ -333,5 +333,3 @@ class WatchlistViewModel @Inject constructor( _uiState.value = _uiState.value.copy(toastMessage = null) } } - - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt b/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt index 39dd0bb2..f5996c8a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt @@ -90,4 +90,3 @@ object ArvioSkin { @ReadOnlyComposable get() = tokens.focus } - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkinTokens.kt b/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkinTokens.kt index 498b1224..48519f18 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkinTokens.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkinTokens.kt @@ -191,4 +191,3 @@ data class ArvioSkinTokens( } } } - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/startup/StartupViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/startup/StartupViewModel.kt index 2e9530ce..57828ff9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/startup/StartupViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/startup/StartupViewModel.kt @@ -133,4 +133,3 @@ class StartupViewModel @Inject constructor( } } } - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/theme/AnimationConstants.kt b/app/src/main/kotlin/com/arflix/tv/ui/theme/AnimationConstants.kt index 4e00a8ed..bc4e80bb 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/theme/AnimationConstants.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/theme/AnimationConstants.kt @@ -83,23 +83,23 @@ object AnimationConstants { /** Scroll spring for smooth deceleration */ const val SPRING_STIFFNESS_SCROLL = 300f const val SPRING_DAMPING_SCROLL = 0.9f - + // ============================================ // EASING CURVES // ============================================ - + /** Standard easing - ease out for responsive feel */ val EaseOut = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f) - + /** Fast out, slow in - for emphasis */ val FastOutSlowIn = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f) - + /** Ease in out - for symmetric animations */ val EaseInOut = CubicBezierEasing(0.42f, 0.0f, 0.58f, 1.0f) - + /** Sharp ease - for quick snappy movements */ val Sharp = CubicBezierEasing(0.4f, 0.0f, 0.6f, 1.0f) - + /** Decelerate - for elements coming to rest */ val Decelerate = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f) @@ -118,17 +118,17 @@ object AnimationConstants { /** Modal/overlay elevation */ const val ELEVATION_MODAL = 48 - + // ============================================ // BORDER & GLOW // ============================================ - + /** Focus ring width */ const val BORDER_FOCUS_WIDTH = 3 - + /** Glow blur radius for focus effect */ const val GLOW_RADIUS_FOCUS = 16 - + /** Ambient glow radius */ const val GLOW_RADIUS_AMBIENT = 8 } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/theme/Color.kt b/app/src/main/kotlin/com/arflix/tv/ui/theme/Color.kt index fbf7b3b7..71a10a24 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/theme/Color.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/theme/Color.kt @@ -143,4 +143,3 @@ val ParticlePurpleDark = ArcticBlack50 val ArvioAccent = ArcticWhite val ArvioPurple = ArcticBlack val ArvioLight = ArcticWhite70 - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/theme/Fonts.kt b/app/src/main/kotlin/com/arflix/tv/ui/theme/Fonts.kt index 62481e81..73e373c2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/theme/Fonts.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/theme/Fonts.kt @@ -19,4 +19,3 @@ val InterFontFamily = FontFamily( Font(R.font.inter_variablefont_opsz_wght, weight = FontWeight.Bold), Font(R.font.inter_variablefont_opsz_wght, weight = FontWeight.Black), ) - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt b/app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt index 3d55bee2..393c6b9c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt @@ -23,7 +23,7 @@ data class ArvioColors( val arcticWhite50: androidx.compose.ui.graphics.Color = ArcticWhite50, val arcticBlack: androidx.compose.ui.graphics.Color = ArcticBlack, val arcticGray: androidx.compose.ui.graphics.Color = ArcticGray, - + // Legacy gradient colors (mapped to Arctic style) val cyan: androidx.compose.ui.graphics.Color = ArcticWhite, val cyanDark: androidx.compose.ui.graphics.Color = ArcticGray, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/theme/Typography.kt b/app/src/main/kotlin/com/arflix/tv/ui/theme/Typography.kt index f051e662..aee216a6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/theme/Typography.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/theme/Typography.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.unit.sp * Arflix typography - TV-optimized text styles (scaled for 1080p TV) */ object ArflixTypography { - + // Hero title (large display) - reduced from 72sp val heroTitle = TextStyle( fontFamily = InterFontFamily, @@ -17,7 +17,7 @@ object ArflixTypography { letterSpacing = (-1).sp, lineHeight = 52.sp ) - + // Section headers - reduced from 28sp val sectionTitle = TextStyle( fontFamily = InterFontFamily, @@ -26,7 +26,7 @@ object ArflixTypography { letterSpacing = 0.5.sp, // Added letter spacing for premium feel lineHeight = 26.sp ) - + // Card titles - slightly larger for TV visibility val cardTitle = TextStyle( fontFamily = InterFontFamily, @@ -35,7 +35,7 @@ object ArflixTypography { letterSpacing = 0.sp, lineHeight = 20.sp ) - + // Body text - reduced from 16sp val body = TextStyle( fontFamily = InterFontFamily, @@ -44,7 +44,7 @@ object ArflixTypography { letterSpacing = 0.sp, lineHeight = 20.sp ) - + // Body large (for hero overview) - reduced from 24sp val bodyLarge = TextStyle( fontFamily = InterFontFamily, @@ -53,7 +53,7 @@ object ArflixTypography { letterSpacing = 0.sp, lineHeight = 24.sp ) - + // Caption / small text - reduced from 12sp val caption = TextStyle( fontFamily = InterFontFamily, @@ -62,7 +62,7 @@ object ArflixTypography { letterSpacing = 0.4.sp, lineHeight = 14.sp ) - + // Label (metadata pills) - reduced from 14sp val label = TextStyle( fontFamily = InterFontFamily, @@ -71,7 +71,7 @@ object ArflixTypography { letterSpacing = 0.4.sp, lineHeight = 16.sp ) - + // Button text val button = TextStyle( fontFamily = InterFontFamily, @@ -80,7 +80,7 @@ object ArflixTypography { letterSpacing = 0.5.sp, lineHeight = 20.sp ) - + // Clock display - reduced from 32sp val clock = TextStyle( fontFamily = InterFontFamily, @@ -89,7 +89,7 @@ object ArflixTypography { letterSpacing = 0.sp, lineHeight = 30.sp ) - + // Episode number badge - reduced from 11sp val badge = TextStyle( fontFamily = InterFontFamily, @@ -99,4 +99,3 @@ object ArflixTypography { lineHeight = 12.sp ) } - diff --git a/app/src/main/kotlin/com/arflix/tv/util/AnimeMapper.kt b/app/src/main/kotlin/com/arflix/tv/util/AnimeMapper.kt index 5f10d9ac..936c1919 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/AnimeMapper.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/AnimeMapper.kt @@ -1078,4 +1078,3 @@ class AnimeMapper @Inject constructor( ) } } - diff --git a/app/src/main/kotlin/com/arflix/tv/util/CatalogUrlParser.kt b/app/src/main/kotlin/com/arflix/tv/util/CatalogUrlParser.kt index f724d862..ccd1c39c 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/CatalogUrlParser.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/CatalogUrlParser.kt @@ -70,4 +70,3 @@ object CatalogUrlParser { return host == "mdblist.com" || host.endsWith(".mdblist.com") } } - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 77cdf554..a7c9b504 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -40,4 +40,3 @@ #EDEDED #EDEDED - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c163af2c..64e1429f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,4 +252,32 @@ 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/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 5c9a5ca6..c118fff8 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -58,4 +58,3 @@ @color/accent_white - diff --git a/app/src/play/kotlin/com/arflix/tv/core/plugin/PluginManager.kt b/app/src/play/kotlin/com/arflix/tv/core/plugin/PluginManager.kt new file mode 100644 index 00000000..98b99f9e --- /dev/null +++ b/app/src/play/kotlin/com/arflix/tv/core/plugin/PluginManager.kt @@ -0,0 +1,80 @@ +package com.arflix.tv.core.plugin + +import com.arflix.tv.domain.model.LocalScraperResult +import com.arflix.tv.domain.model.PluginRepository +import com.arflix.tv.domain.model.RemotePluginInfo +import com.arflix.tv.domain.model.ScraperInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PluginManager @Inject constructor() { + + val repositories: Flow> = flowOf(emptyList()) + val scrapers: Flow> = flowOf(emptyList()) + val pluginsEnabled: Flow = flowOf(false) + val groupStreamsByRepository: Flow = flowOf(false) + var isSyncingFromRemote = false + + fun flushPendingSync() {} + + val enabledScrapers: Flow> = flowOf(emptyList()) + + suspend fun addRepository(manifestUrl: String): Result { + return Result.failure(Exception("Plugins are not supported in this version")) + } + + suspend fun removeRepository(repoId: String) {} + + suspend fun reconcileWithRemoteRepoUrls( + remotePlugins: List, + removeMissingLocal: Boolean = true + ) {} + + @JvmName("reconcileWithRemoteRepoUrlStrings") + suspend fun reconcileWithRemoteRepoUrls( + remoteUrls: List, + removeMissingLocal: Boolean = true + ) {} + + suspend fun refreshRepository(repoId: String): Result { + return Result.failure(Exception("Plugins are not supported in this version")) + } + + suspend fun toggleScraper(scraperId: String, enabled: Boolean) {} + + suspend fun toggleAllScrapersForRepo(repoId: String, enabled: Boolean) {} + + suspend fun setPluginsEnabled(enabled: Boolean) {} + + suspend fun setGroupStreamsByRepository(enabled: Boolean) {} + + suspend fun executeScrapers( + tmdbId: String, + mediaType: String, + season: Int? = null, + episode: Int? = null + ): List = emptyList() + + fun executeScrapersStreaming( + tmdbId: String, + mediaType: String, + season: Int? = null, + episode: Int? = null + ): Flow>> = emptyFlow() + + suspend fun executeScraper( + scraper: ScraperInfo, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int? + ): List = emptyList() + + suspend fun testScraper(scraperId: String): Result, TestDiagnostics>> { + return Result.failure(Exception("Plugins are not supported in this version")) + } +} 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/play/kotlin/com/arflix/tv/data/local/ProfileDataStoreFactory.kt b/app/src/play/kotlin/com/arflix/tv/data/local/ProfileDataStoreFactory.kt new file mode 100644 index 00000000..94a6033b --- /dev/null +++ b/app/src/play/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/core/plugin/PluginManager.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt new file mode 100644 index 00000000..77a526fa --- /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..3f348cda --- /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..6ae700f9 --- /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..c8f31368 --- /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..8a59a278 --- /dev/null +++ b/app/src/sideload/kotlin/com/lagradost/cloudstream3/AcraApplication.kt @@ -0,0 +1,19 @@ +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..9758429a 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 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