commit d9c03814390e5b91c6935359fbfbcc68635bd75a Author: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun Feb 1 19:56:04 2026 +0530 Initial commit: NuvioTV as clean branch diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 00000000..b3405b3b --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +My Application \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 00000000..b86273d9 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 00000000..4ea72a91 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 00000000..7ef04e2e --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 00000000..1f2ea11e --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 00000000..8648f940 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..0d3beb0f --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 00000000..91f95584 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 00000000..639c779c --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..f0c6ad08 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..b2c751a3 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 00000000..16660f1d --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..496c564a --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.kotlin/errors/errors-1769668942713.log b/.kotlin/errors/errors-1769668942713.log new file mode 100644 index 00000000..1219b509 --- /dev/null +++ b/.kotlin/errors/errors-1769668942713.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..590ad89d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,115 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.nuvio.tv" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "com.nuvio.tv" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material:material-icons-extended") + implementation(libs.androidx.tv.foundation) + implementation(libs.androidx.tv.material) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + + // Networking + implementation(libs.retrofit) + implementation(libs.retrofit.moshi) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.moshi) + ksp(libs.moshi.codegen) + + // Coroutines + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + // Image Loading + implementation(libs.coil.compose) + implementation(libs.coil.svg) + + // Navigation + implementation(libs.navigation.compose) + + // DataStore + implementation(libs.datastore.preferences) + + // ViewModel + implementation(libs.lifecycle.viewmodel.compose) + + // Media3 ExoPlayer + implementation(libs.media3.exoplayer) + implementation(libs.media3.exoplayer.hls) + implementation(libs.media3.exoplayer.dash) + implementation(libs.media3.ui) + implementation(libs.media3.session) + implementation(libs.media3.common) + + // Media3 FFmpeg Decoder Extension (locally built AAR) + implementation(files("libs/media3-decoder-ffmpeg.aar")) + + // Local Plugin System + implementation(libs.quickjs.kt) + implementation(libs.jsoup) + implementation(libs.gson) + + // Bundle real crypto-js (JS) for QuickJS plugins + implementation("org.webjars.npm:crypto-js:4.2.0") + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/libs/media3-decoder-ffmpeg.aar b/app/libs/media3-decoder-ffmpeg.aar new file mode 100644 index 00000000..9563e508 Binary files /dev/null and b/app/libs/media3-decoder-ffmpeg.aar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1634baaf --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/nuvio/tv/MainActivity.kt b/app/src/main/java/com/nuvio/tv/MainActivity.kt new file mode 100644 index 00000000..0c7b75e2 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/MainActivity.kt @@ -0,0 +1,179 @@ +package com.nuvio.tv + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.background +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.rememberNavController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.DrawerValue +import androidx.tv.material3.Icon +import androidx.tv.material3.NavigationDrawer +import androidx.tv.material3.NavigationDrawerItem +import androidx.tv.material3.Text +import androidx.tv.material3.rememberDrawerState +import androidx.tv.material3.Surface +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import coil.compose.AsyncImage +import com.nuvio.tv.ui.navigation.NuvioNavHost +import com.nuvio.tv.ui.navigation.Screen +import com.nuvio.tv.ui.theme.NuvioTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @OptIn(ExperimentalTvMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + NuvioTheme { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RectangleShape + ) { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + + val rootRoutes = setOf( + Screen.Home.route, + Screen.Search.route, + Screen.Library.route, + Screen.Settings.route, + Screen.AddonManager.route + ) + + LaunchedEffect(currentRoute) { + if (currentRoute in rootRoutes) { + drawerState.setValue(DrawerValue.Closed) + } else { + drawerState.setValue(DrawerValue.Closed) + } + } + + BackHandler(enabled = currentRoute in rootRoutes && drawerState.currentValue == DrawerValue.Closed) { + drawerState.setValue(DrawerValue.Open) + } + + val drawerItems = listOf( + Screen.Home.route to ("Home" to Icons.Filled.Home), + Screen.Search.route to ("Search" to Icons.Filled.Search), + Screen.Library.route to ("Library" to Icons.Filled.Bookmark), + Screen.AddonManager.route to ("Addons" to Icons.Filled.Extension), + Screen.Settings.route to ("Settings" to Icons.Filled.Settings) + ) + + val showSidebar = currentRoute in rootRoutes + if (showSidebar) { + NavigationDrawer( + drawerState = drawerState, + drawerContent = { drawerValue -> + val drawerWidth = if (drawerValue == DrawerValue.Open) 260.dp else 72.dp + Column( + modifier = Modifier + .fillMaxHeight() + .width(drawerWidth) + .background( + Brush.horizontalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.7f), + Color.Black.copy(alpha = 0.35f), + Color.Transparent + ) + ) + ) + .padding(12.dp) + .selectableGroup(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (drawerValue == DrawerValue.Open) { + Image( + painter = painterResource(id = R.drawable.nuvio_text), + contentDescription = "Nuvio", + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + contentScale = ContentScale.Fit + ) + } else { + Image( + painter = painterResource(id = R.drawable.nuvio_n), + contentDescription = "Nuvio", + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + contentScale = ContentScale.Fit + ) + } + drawerItems.forEach { (route, item) -> + val (label, icon) = item + NavigationDrawerItem( + selected = currentRoute == route, + onClick = { + if (currentRoute != route) { + navController.navigate(route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + drawerState.setValue(DrawerValue.Closed) + }, + leadingContent = { + Icon(imageVector = icon, contentDescription = null) + } + ) { + if (drawerValue == DrawerValue.Open) { + Text(label) + } + } + } + } + } + ) { + Box(modifier = Modifier.fillMaxSize()) { + NuvioNavHost(navController = navController) + } + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + NuvioNavHost(navController = navController) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/NuvioApplication.kt b/app/src/main/java/com/nuvio/tv/NuvioApplication.kt new file mode 100644 index 00000000..4e01c5f0 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/NuvioApplication.kt @@ -0,0 +1,7 @@ +package com.nuvio.tv + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class NuvioApplication : Application() diff --git a/app/src/main/java/com/nuvio/tv/core/di/NetworkModule.kt b/app/src/main/java/com/nuvio/tv/core/di/NetworkModule.kt new file mode 100644 index 00000000..56c06ad9 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/di/NetworkModule.kt @@ -0,0 +1,67 @@ +package com.nuvio.tv.core.di + +import com.nuvio.tv.data.remote.api.AddonApi +import com.nuvio.tv.data.remote.api.TmdbApi +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideMoshi(): Moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + .build() + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit = + Retrofit.Builder() + .baseUrl("https://placeholder.nuvio.tv/") + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + @Provides + @Singleton + @Named("tmdb") + fun provideTmdbRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit = + Retrofit.Builder() + .baseUrl("https://api.themoviedb.org/3/") + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + @Provides + @Singleton + fun provideAddonApi(retrofit: Retrofit): AddonApi = + retrofit.create(AddonApi::class.java) + + @Provides + @Singleton + fun provideTmdbApi(@Named("tmdb") retrofit: Retrofit): TmdbApi = + retrofit.create(TmdbApi::class.java) +} diff --git a/app/src/main/java/com/nuvio/tv/core/di/RepositoryModule.kt b/app/src/main/java/com/nuvio/tv/core/di/RepositoryModule.kt new file mode 100644 index 00000000..3b7edfde --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/di/RepositoryModule.kt @@ -0,0 +1,42 @@ +package com.nuvio.tv.core.di + +import com.nuvio.tv.data.repository.AddonRepositoryImpl +import com.nuvio.tv.data.repository.CatalogRepositoryImpl +import com.nuvio.tv.data.repository.MetaRepositoryImpl +import com.nuvio.tv.data.repository.StreamRepositoryImpl +import com.nuvio.tv.data.repository.WatchProgressRepositoryImpl +import com.nuvio.tv.domain.repository.AddonRepository +import com.nuvio.tv.domain.repository.CatalogRepository +import com.nuvio.tv.domain.repository.MetaRepository +import com.nuvio.tv.domain.repository.StreamRepository +import com.nuvio.tv.domain.repository.WatchProgressRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindAddonRepository(impl: AddonRepositoryImpl): AddonRepository + + @Binds + @Singleton + abstract fun bindCatalogRepository(impl: CatalogRepositoryImpl): CatalogRepository + + @Binds + @Singleton + abstract fun bindMetaRepository(impl: MetaRepositoryImpl): MetaRepository + + @Binds + @Singleton + abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository + + @Binds + @Singleton + abstract fun bindWatchProgressRepository(impl: WatchProgressRepositoryImpl): WatchProgressRepository +} diff --git a/app/src/main/java/com/nuvio/tv/core/network/NetworkResult.kt b/app/src/main/java/com/nuvio/tv/core/network/NetworkResult.kt new file mode 100644 index 00000000..cb64dcfa --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/network/NetworkResult.kt @@ -0,0 +1,7 @@ +package com.nuvio.tv.core.network + +sealed class NetworkResult { + data class Success(val data: T) : NetworkResult() + data class Error(val message: String, val code: Int? = null) : NetworkResult() + data object Loading : NetworkResult() +} diff --git a/app/src/main/java/com/nuvio/tv/core/network/SafeApiCall.kt b/app/src/main/java/com/nuvio/tv/core/network/SafeApiCall.kt new file mode 100644 index 00000000..c91c696c --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/network/SafeApiCall.kt @@ -0,0 +1,18 @@ +package com.nuvio.tv.core.network + +import retrofit2.Response + +suspend fun safeApiCall(apiCall: suspend () -> Response): NetworkResult { + return try { + val response = apiCall() + if (response.isSuccessful) { + response.body()?.let { + NetworkResult.Success(it) + } ?: NetworkResult.Error("Empty response body") + } else { + NetworkResult.Error(response.message(), response.code()) + } + } catch (e: Exception) { + NetworkResult.Error(e.message ?: "Unknown error occurred") + } +} diff --git a/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt b/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt new file mode 100644 index 00000000..bc92bfa7 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt @@ -0,0 +1,483 @@ +package com.nuvio.tv.core.plugin + +import android.util.Log +import com.nuvio.tv.data.local.PluginDataStore +import com.nuvio.tv.domain.model.LocalScraperResult +import com.nuvio.tv.domain.model.PluginManifest +import com.nuvio.tv.domain.model.PluginRepository +import com.nuvio.tv.domain.model.ScraperInfo +import com.nuvio.tv.domain.model.ScraperManifestInfo +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.channels.Channel +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 okhttp3.OkHttpClient +import okhttp3.Request +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "PluginManager" +private const val MAX_CONCURRENT_SCRAPERS = 5 +private const val MAX_RESULT_ITEMS = 150 +private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L + +@Singleton +class PluginManager @Inject constructor( + private val dataStore: PluginDataStore, + private val runtime: PluginRuntime +) { + 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() + } + + // Single-flight map to prevent duplicate scraper executions + private val inFlightScrapers = ConcurrentHashMap>>() + + // Semaphore to limit concurrent scrapers + private val scraperSemaphore = Semaphore(MAX_CONCURRENT_SCRAPERS) + + // 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 + + // 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 + */ + suspend fun addRepository(manifestUrl: String): Result = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Adding repository from: $manifestUrl") + + // Fetch manifest + val manifest = fetchManifest(manifestUrl) + ?: return@withContext Result.failure(Exception("Failed to fetch manifest")) + + // Create repository + val repo = PluginRepository( + id = UUID.randomUUID().toString(), + name = manifest.name, + url = manifestUrl, + enabled = true, + lastUpdated = System.currentTimeMillis(), + scraperCount = manifest.scrapers.size + ) + + // Save repository + dataStore.addRepository(repo) + + // Download and save scrapers + downloadScrapers(repo.id, manifestUrl, manifest.scrapers) + + Log.d(TAG, "Repository added: ${repo.name} with ${manifest.scrapers.size} scrapers") + Result.success(repo) + + } catch (e: Exception) { + Log.e(TAG, "Failed to add repository: ${e.message}", e) + Result.failure(e) + } + } + + /** + * Remove a repository and its scrapers + */ + suspend fun removeRepository(repoId: String) { + val scraperList = dataStore.scrapers.first() + + // Remove all scrapers from this repo + scraperList.filter { it.repositoryId == repoId }.forEach { scraper -> + dataStore.deleteScraperCode(scraper.id) + } + + // Remove scrapers from list + val updatedScrapers = scraperList.filter { it.repositoryId != repoId } + dataStore.saveScrapers(updatedScrapers) + + // Remove repository + dataStore.removeRepository(repoId) + } + + /** + * 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")) + + 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.scrapers.size + ) + dataStore.updateRepository(updatedRepo) + + // Re-download scrapers + downloadScrapers(repo.id, repo.url, manifest.scrapers) + + Result.success(Unit) + } catch (e: Exception) { + Log.e(TAG, "Failed to refresh repository: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 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 plugins globally enabled + */ + suspend fun setPluginsEnabled(enabled: Boolean) { + dataStore.setPluginsEnabled(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") + + 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") + + // Launch all scrapers concurrently within the channelFlow scope + enabledList.forEach { scraper -> + launch { + try { + val results = executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode) + if (results.isNotEmpty()) { + send(scraper.name to results) + } + } catch (e: Exception) { + Log.e(TAG, "Scraper ${scraper.name} failed in streaming: ${e.message}") + } + } + } + } + + /** + * 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 + */ + suspend fun executeScraper( + 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 = runtime.executePlugin( + code = code, + tmdbId = tmdbId, + mediaType = mediaType, + season = season, + episode = episode, + scraperId = scraper.id, + scraperSettings = settings + ) + + 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() + } + } + + /** + * Test a scraper with sample data + */ + suspend fun testScraper(scraperId: String): Result> { + val scraper = dataStore.scrapers.first().find { it.id == scraperId } + ?: return Result.failure(Exception("Scraper not found")) + + // Use a popular movie for testing (The Matrix - 603) + val testTmdbId = "603" + val testMediaType = if (scraper.supportsType("movie")) "movie" else "series" + + return try { + val results = executeScraper(scraper, testTmdbId, testMediaType, 1, 1) + Result.success(results) + } catch (e: Exception) { + Result.failure(e) + } + } + + private suspend fun fetchManifest(url: String): PluginManifest? = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url(url) + .header("User-Agent", "NuvioTV/1.0") + .build() + + val response = httpClient.newCall(request).execute() + 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 downloadScrapers( + 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 headResponse = httpClient.newCall(headRequest).execute() + val contentLength = 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 codeResponse = httpClient.newCall(codeRequest).execute() + if (!codeResponse.isSuccessful) { + Log.e(TAG, "Failed to download scraper ${info.name}: ${codeResponse.code}") + return@forEach + } + + val code = 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 scraper = ScraperInfo( + id = scraperId, + repositoryId = repoId, + name = info.name, + description = info.description ?: "", + version = info.version, + filename = info.filename, + supportedTypes = info.supportedTypes, + enabled = true, + 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) + } +} diff --git a/app/src/main/java/com/nuvio/tv/core/plugin/PluginRuntime.kt b/app/src/main/java/com/nuvio/tv/core/plugin/PluginRuntime.kt new file mode 100644 index 00000000..23c08e4a --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/plugin/PluginRuntime.kt @@ -0,0 +1,1029 @@ +package com.nuvio.tv.core.plugin + +import android.util.Log +import com.dokar.quickjs.binding.asyncFunction +import com.dokar.quickjs.binding.define +import com.dokar.quickjs.binding.function +import com.dokar.quickjs.quickJs +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.nuvio.tv.domain.model.LocalScraperResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +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.ByteArrayInputStream +import java.net.URL +import java.security.MessageDigest +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.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "PluginRuntime" +private const val PLUGIN_TIMEOUT_MS = 60_000L + +@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) + .build() + + // Store parsed documents for cheerio + private val documentCache = ConcurrentHashMap() + private val elementCache = ConcurrentHashMap() + + // 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 + */ + suspend fun executePlugin( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map = emptyMap() + ): List = withContext(Dispatchers.IO) { + 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 { + // Clear caches before execution + documentCache.clear() + elementCache.clear() + + var resultJson = "[]" + + try { + quickJs(Dispatchers.IO) { + // Define console object + define("console") { + function("log") { args -> + Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + } + function("error") { args -> + Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + } + function("warn") { args -> + Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + } + function("info") { args -> + Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + } + function("debug") { args -> + Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" }) + } + } + + // Define native fetch function (async) + 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() ?: "" + performNativeFetch(url, method, headersJson, body) + } + + // 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 + function("__capture_result") { args -> + resultJson = args.getOrNull(0)?.toString() ?: "[]" + } + + // 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 + val wrappedCode = """ + var module = { exports: {} }; + var exports = module.exports; + $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) { + __capture_result(JSON.stringify([])); + return; + } + var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); + __capture_result(JSON.stringify(result || [])); + } catch (e) { + console.error("getStreams error:", e.message || e); + __capture_result(JSON.stringify([])); + } + })(); + """.trimIndent() + + evaluate(callCode) + } + + 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() + } + } + + private fun performNativeFetch(url: String, method: String, headersJson: String, body: String): String { + return 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 callers set Accept-Encoding manually, OkHttp will not transparently decompress. + // Strip it so OkHttp can negotiate and decode automatically. + if (!key.equals("Accept-Encoding", ignoreCase = true)) { + headers[key] = v.toString() + } + } + } + } catch (e: Exception) { + // Ignore header parsing errors + } + + // Default User-Agent + 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.toRequestBody(contentType.toMediaType())) + } + "PUT" -> { + val contentType = headers["Content-Type"] ?: "application/json" + requestBuilder.put(body.toRequestBody(contentType.toMediaType())) + } + "DELETE" -> requestBuilder.delete() + else -> requestBuilder.get() + } + + val request = requestBuilder.build() + val response = httpClient.newCall(request).execute() + + val responseBodyBytes = response.body?.bytes() ?: ByteArray(0) + val contentEncoding = response.header("Content-Encoding")?.lowercase()?.trim() + val decodedBytes = try { + when (contentEncoding) { + "gzip" -> GZIPInputStream(ByteArrayInputStream(responseBodyBytes)).use { it.readBytes() } + "deflate" -> InflaterInputStream(ByteArrayInputStream(responseBodyBytes)).use { it.readBytes() } + else -> responseBodyBytes + } + } catch (e: Exception) { + // If decoding fails, fall back to raw bytes. + responseBodyBytes + } + + val charset = response.body?.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + val responseBody = try { + String(decodedBytes, charset) + } catch (e: Exception) { + String(decodedBytes, Charsets.UTF_8) + } + val responseHeaders = mutableMapOf() + response.headers.forEach { (name, value) -> + responseHeaders[name.lowercase()] = value + } + + val result = mapOf( + "ok" to response.isSuccessful, + "status" to response.code, + "statusText" to response.message, + "url" to response.request.url.toString(), + "body" to responseBody, + "headers" to responseHeaders + ) + + gson.toJson(result) + } catch (e: Exception) { + Log.e(TAG, "Fetch error: ${e.message}") + gson.toJson(mapOf( + "ok" to false, + "status" to 0, + "statusText" to (e.message ?: "Fetch failed"), + "url" to url, + "body" to "", + "headers" to emptyMap() + )) + } + } + + 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 = "1865f43a0549ca50d341dd9ab8b29f49"; + } + + // 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 || ''; + + // 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); + + 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 { + return Promise.resolve(JSON.parse(parsed.body)); + } catch (e) { + return Promise.reject(new Error('JSON parse error: ' + e.message)); + } + } + }; + }; + + // URL class + var URL = function(urlString) { + var parsed = __parse_url(urlString); + var data = JSON.parse(parsed); + this.href = urlString; + 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; + }; + + // 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]; + }; + + // 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/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt new file mode 100644 index 00000000..35c99d91 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt @@ -0,0 +1,212 @@ +package com.nuvio.tv.core.tmdb + +import android.util.Log +import com.nuvio.tv.data.remote.api.TmdbApi +import com.nuvio.tv.data.remote.api.TmdbEpisode +import com.nuvio.tv.domain.model.ContentType +import com.nuvio.tv.domain.model.MetaCastMember +import com.nuvio.tv.domain.model.MetaCompany +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "TmdbMetadataService" +private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c" + +@Singleton +class TmdbMetadataService @Inject constructor( + private val tmdbApi: TmdbApi +) { + suspend fun fetchEnrichment(tmdbId: String, contentType: ContentType): TmdbEnrichment? = + withContext(Dispatchers.IO) { + val numericId = tmdbId.toIntOrNull() ?: return@withContext null + val tmdbType = when (contentType) { + ContentType.SERIES, ContentType.TV -> "tv" + else -> "movie" + } + + try { + val details = when (tmdbType) { + "tv" -> tmdbApi.getTvDetails(numericId, TMDB_API_KEY) + else -> tmdbApi.getMovieDetails(numericId, TMDB_API_KEY) + }.body() + + val credits = when (tmdbType) { + "tv" -> tmdbApi.getTvCredits(numericId, TMDB_API_KEY) + else -> tmdbApi.getMovieCredits(numericId, TMDB_API_KEY) + }.body() + + val images = when (tmdbType) { + "tv" -> tmdbApi.getTvImages(numericId, TMDB_API_KEY) + else -> tmdbApi.getMovieImages(numericId, TMDB_API_KEY) + }.body() + + val genres = details?.genres?.mapNotNull { genre -> + genre.name.trim().takeIf { name -> name.isNotBlank() } + } ?: emptyList() + val description = details?.overview?.takeIf { it.isNotBlank() } + val releaseInfo = details?.releaseDate + ?: details?.firstAirDate + val rating = details?.voteAverage + val runtime = details?.runtime ?: details?.episodeRunTime?.firstOrNull() + val countries = details?.productionCountries + ?.mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } } + ?.takeIf { it.isNotEmpty() } + ?: details?.originCountry?.takeIf { it.isNotEmpty() } + val language = details?.originalLanguage?.takeIf { it.isNotBlank() } + val productionCompanies = details?.productionCompanies + .orEmpty() + .mapNotNull { company -> + val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + MetaCompany( + name = name, + logo = buildImageUrl(company.logoPath, size = "w300") + ) + } + val networks = details?.networks + .orEmpty() + .mapNotNull { network -> + val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + MetaCompany( + name = name, + logo = buildImageUrl(network.logoPath, size = "w300") + ) + } + val poster = buildImageUrl(details?.posterPath, size = "w500") + val backdrop = buildImageUrl(details?.backdropPath, size = "w1280") + + val logoPath = images?.logos + ?.sortedWith( + compareByDescending { it.iso6391 == "en" } + .thenByDescending { it.iso6391 == null } + ) + ?.firstOrNull() + ?.filePath + + val logo = buildImageUrl(logoPath, size = "w500") + + val castMembers = credits?.cast + .orEmpty() + .mapNotNull { member -> + val name = member.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + MetaCastMember( + name = name, + character = member.character?.takeIf { it.isNotBlank() }, + photo = buildImageUrl(member.profilePath, size = "w500") + ) + } + + val director = credits?.crew + .orEmpty() + .filter { it.job.equals("Director", ignoreCase = true) } + .mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } } + + val writer = credits?.crew + .orEmpty() + .filter { crew -> + val job = crew.job?.lowercase() ?: "" + job.contains("writer") || job.contains("screenplay") + } + .mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } } + + if ( + genres.isEmpty() && description == null && backdrop == null && logo == null && + poster == null && castMembers.isEmpty() && director.isEmpty() && writer.isEmpty() && + releaseInfo == null && rating == null && runtime == null && countries.isNullOrEmpty() && language == null && + productionCompanies.isEmpty() && networks.isEmpty() + ) { + return@withContext null + } + + TmdbEnrichment( + description = description, + genres = genres, + backdrop = backdrop, + logo = logo, + poster = poster, + castMembers = castMembers, + releaseInfo = releaseInfo, + rating = rating, + runtimeMinutes = runtime, + director = director, + writer = writer, + productionCompanies = productionCompanies, + networks = networks, + countries = countries, + language = language + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e) + null + } + } + + suspend fun fetchEpisodeEnrichment( + tmdbId: String, + seasonNumbers: List + ): Map, TmdbEpisodeEnrichment> = withContext(Dispatchers.IO) { + val numericId = tmdbId.toIntOrNull() ?: return@withContext emptyMap() + val result = mutableMapOf, TmdbEpisodeEnrichment>() + + seasonNumbers.distinct().forEach { season -> + try { + val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY) + val episodes = response.body()?.episodes.orEmpty() + episodes.forEach { ep -> + val epNum = ep.episodeNumber ?: return@forEach + result[season to epNum] = ep.toEnrichment() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}") + } + } + + result + } + + private fun buildImageUrl(path: String?, size: String): String? { + val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null + return "https://image.tmdb.org/t/p/$size$clean" + } +} + +data class TmdbEnrichment( + val description: String?, + val genres: List, + val backdrop: String?, + val logo: String?, + val poster: String?, + val castMembers: List, + val releaseInfo: String?, + val rating: Double?, + val runtimeMinutes: Int?, + val director: List, + val writer: List, + val productionCompanies: List, + val networks: List, + val countries: List?, + val language: String? +) + +data class TmdbEpisodeEnrichment( + val title: String?, + val overview: String?, + val thumbnail: String?, + val airDate: String?, + val runtimeMinutes: Int? +) + +private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment { + val title = name?.takeIf { it.isNotBlank() } + val overview = overview?.takeIf { it.isNotBlank() } + val thumbnail = stillPath?.takeIf { it.isNotBlank() }?.let { "https://image.tmdb.org/t/p/w500$it" } + val airDate = airDate?.takeIf { it.isNotBlank() } + return TmdbEpisodeEnrichment( + title = title, + overview = overview, + thumbnail = thumbnail, + airDate = airDate, + runtimeMinutes = runtime + ) +} diff --git a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt new file mode 100644 index 00000000..1bd8312b --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt @@ -0,0 +1,215 @@ +package com.nuvio.tv.core.tmdb + +import android.util.Log +import com.nuvio.tv.data.remote.api.TmdbApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "TmdbService" +private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c" + +/** + * Service to handle TMDB ID conversions and lookups. + * Provides caching to avoid redundant API calls. + */ +@Singleton +class TmdbService @Inject constructor( + private val tmdbApi: TmdbApi +) { + // Cache: IMDB ID -> TMDB ID + private val imdbToTmdbCache = ConcurrentHashMap() + + // Cache: TMDB ID -> IMDB ID + private val tmdbToImdbCache = ConcurrentHashMap() + + // Mutex for thread-safe cache operations + private val cacheMutex = Mutex() + + /** + * Convert an IMDB ID to a TMDB ID. + * + * @param imdbId The IMDB ID (e.g., "tt0133093") + * @param mediaType The media type ("movie" or "series"/"tv") + * @return The TMDB ID, or null if not found + */ + suspend fun imdbToTmdb(imdbId: String, mediaType: String): Int? = withContext(Dispatchers.IO) { + // Validate IMDB ID format + if (!imdbId.startsWith("tt")) { + Log.w(TAG, "Invalid IMDB ID format: $imdbId") + return@withContext null + } + + // Check cache first + imdbToTmdbCache[imdbId]?.let { cached -> + Log.d(TAG, "Cache hit: IMDB $imdbId -> TMDB $cached") + return@withContext cached + } + + try { + Log.d(TAG, "Looking up TMDB ID for IMDB: $imdbId (type: $mediaType)") + + val response = tmdbApi.findByExternalId( + externalId = imdbId, + apiKey = TMDB_API_KEY, + externalSource = "imdb_id" + ) + + if (!response.isSuccessful) { + Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}") + return@withContext null + } + + val body = response.body() ?: return@withContext null + + // Determine which results to use based on media type + val normalizedType = normalizeMediaType(mediaType) + val result = when (normalizedType) { + "movie" -> body.movieResults?.firstOrNull() + "tv", "series" -> body.tvResults?.firstOrNull() + else -> body.movieResults?.firstOrNull() ?: body.tvResults?.firstOrNull() + } + + result?.let { found -> + Log.d(TAG, "Found TMDB ID: ${found.id} for IMDB: $imdbId") + + // Cache both directions + cacheMutex.withLock { + imdbToTmdbCache[imdbId] = found.id + tmdbToImdbCache[found.id] = imdbId + } + + return@withContext found.id + } + + Log.w(TAG, "No TMDB result found for IMDB: $imdbId") + null + + } catch (e: Exception) { + Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e) + null + } + } + + /** + * Convert a TMDB ID to an IMDB ID. + * + * @param tmdbId The TMDB ID + * @param mediaType The media type ("movie" or "series"/"tv") + * @return The IMDB ID, or null if not found + */ + suspend fun tmdbToImdb(tmdbId: Int, mediaType: String): String? = withContext(Dispatchers.IO) { + // Check cache first + tmdbToImdbCache[tmdbId]?.let { cached -> + Log.d(TAG, "Cache hit: TMDB $tmdbId -> IMDB $cached") + return@withContext cached + } + + try { + Log.d(TAG, "Looking up IMDB ID for TMDB: $tmdbId (type: $mediaType)") + + val normalizedType = normalizeMediaType(mediaType) + val response = when (normalizedType) { + "movie" -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY) + "tv", "series" -> tmdbApi.getTvExternalIds(tmdbId, TMDB_API_KEY) + else -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY) + } + + if (!response.isSuccessful) { + Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}") + return@withContext null + } + + val body = response.body() ?: return@withContext null + + body.imdbId?.let { imdbId -> + Log.d(TAG, "Found IMDB ID: $imdbId for TMDB: $tmdbId") + + // Cache both directions + cacheMutex.withLock { + tmdbToImdbCache[tmdbId] = imdbId + imdbToTmdbCache[imdbId] = tmdbId + } + + return@withContext imdbId + } + + Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId") + null + + } catch (e: Exception) { + Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e) + null + } + } + + /** + * Get a TMDB ID from a video ID string. + * Handles both IMDB IDs (tt...) and TMDB IDs. + * + * @param videoId The video ID (can be IMDB or TMDB format) + * @param mediaType The media type + * @return The TMDB ID as a string, or null if conversion failed + */ + suspend fun ensureTmdbId(videoId: String, mediaType: String): String? { + // Check if it's already a TMDB ID (numeric or prefixed) + val cleanId = videoId + .removePrefix("tmdb:") + .removePrefix("movie:") + .removePrefix("series:") + + // Stremio-style series ids can look like: tt1234567:season:episode + // Plugins/TMDB lookup need the base external id only. + val idPart = cleanId + .substringBefore(':') + .substringBefore('/') + .trim() + + // If it's an IMDB ID, convert it + if (idPart.startsWith("tt")) { + val tmdbId = imdbToTmdb(idPart, normalizeMediaType(mediaType)) + return tmdbId?.toString() + } + + // If it looks like a numeric ID, assume it's already a TMDB ID + if (idPart.all { it.isDigit() }) { + return idPart + } + + // Unknown format + Log.w(TAG, "Unknown video ID format: $videoId") + return null + } + + /** + * Normalize media type to consistent format + */ + private fun normalizeMediaType(mediaType: String): String { + return when (mediaType.lowercase()) { + "series", "tv", "show", "tvshow" -> "tv" + "movie", "film" -> "movie" + else -> mediaType.lowercase() + } + } + + /** + * Clear all caches + */ + fun clearCache() { + imdbToTmdbCache.clear() + tmdbToImdbCache.clear() + Log.d(TAG, "Cache cleared") + } + + /** + * Pre-populate cache with known mappings + */ + fun preCacheMapping(imdbId: String, tmdbId: Int) { + imdbToTmdbCache[imdbId] = tmdbId + tmdbToImdbCache[tmdbId] = imdbId + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt b/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt new file mode 100644 index 00000000..ebe6e6ad --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt @@ -0,0 +1,45 @@ +package com.nuvio.tv.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.dataStore: DataStore by preferencesDataStore(name = "addon_preferences") + +@Singleton +class AddonPreferences @Inject constructor( + @ApplicationContext private val context: Context +) { + private val addonUrlsKey = stringSetPreferencesKey("installed_addon_urls") + + val installedAddonUrls: Flow> = context.dataStore.data + .map { preferences -> + preferences[addonUrlsKey] ?: getDefaultAddons() + } + + suspend fun addAddon(url: String) { + context.dataStore.edit { preferences -> + val currentUrls = preferences[addonUrlsKey] ?: getDefaultAddons() + preferences[addonUrlsKey] = currentUrls + url + } + } + + suspend fun removeAddon(url: String) { + context.dataStore.edit { preferences -> + val currentUrls = preferences[addonUrlsKey] ?: emptySet() + preferences[addonUrlsKey] = currentUrls - url + } + } + + private fun getDefaultAddons(): Set = setOf( + "https://v3-cinemeta.strem.io" + ) +} diff --git a/app/src/main/java/com/nuvio/tv/data/local/LibraryPreferences.kt b/app/src/main/java/com/nuvio/tv/data/local/LibraryPreferences.kt new file mode 100644 index 00000000..20163a6e --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/local/LibraryPreferences.kt @@ -0,0 +1,67 @@ +package com.nuvio.tv.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.gson.Gson +import com.nuvio.tv.domain.model.SavedLibraryItem +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.libraryDataStore: DataStore by preferencesDataStore(name = "library_preferences") + +@Singleton +class LibraryPreferences @Inject constructor( + @ApplicationContext private val context: Context +) { + private val gson = Gson() + private val libraryItemsKey = stringSetPreferencesKey("library_items") + + val libraryItems: Flow> = context.libraryDataStore.data + .map { preferences -> + val raw = preferences[libraryItemsKey] ?: emptySet() + raw.mapNotNull { json -> + runCatching { gson.fromJson(json, SavedLibraryItem::class.java) }.getOrNull() + } + } + + fun isInLibrary(itemId: String, itemType: String): Flow { + return libraryItems.map { items -> + items.any { it.id == itemId && it.type.equals(itemType, ignoreCase = true) } + } + } + + suspend fun addItem(item: SavedLibraryItem) { + context.libraryDataStore.edit { preferences -> + val current = preferences[libraryItemsKey] ?: emptySet() + val filtered = current.filterNot { json -> + runCatching { + gson.fromJson(json, SavedLibraryItem::class.java) + }.getOrNull()?.let { saved -> + saved.id == item.id && saved.type.equals(item.type, ignoreCase = true) + } ?: false + } + preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(item) + } + } + + suspend fun removeItem(itemId: String, itemType: String) { + context.libraryDataStore.edit { preferences -> + val current = preferences[libraryItemsKey] ?: emptySet() + val filtered = current.filterNot { json -> + runCatching { + gson.fromJson(json, SavedLibraryItem::class.java) + }.getOrNull()?.let { saved -> + saved.id == itemId && saved.type.equals(itemType, ignoreCase = true) + } ?: false + } + preferences[libraryItemsKey] = filtered.toSet() + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/local/PluginDataStore.kt b/app/src/main/java/com/nuvio/tv/data/local/PluginDataStore.kt new file mode 100644 index 00000000..cf9d923d --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/local/PluginDataStore.kt @@ -0,0 +1,186 @@ +package com.nuvio.tv.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.nuvio.tv.domain.model.PluginRepository +import com.nuvio.tv.domain.model.ScraperInfo +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.pluginDataStore: DataStore by preferencesDataStore(name = "plugin_settings") + +@Singleton +class PluginDataStore @Inject constructor( + @ApplicationContext private val context: Context, + private val moshi: Moshi +) { + private val dataStore = context.pluginDataStore + + private val repositoriesKey = stringPreferencesKey("repositories") + private val scrapersKey = stringPreferencesKey("scrapers") + private val pluginsEnabledKey = booleanPreferencesKey("plugins_enabled") + 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 + private val codeDir: File + get() = File(context.filesDir, "plugin_code").also { it.mkdirs() } + + // Repositories + val repositories: Flow> = dataStore.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) + dataStore.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 + saveRepositories(current) + } + } + + // Scrapers + val scrapers: Flow> = dataStore.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) + dataStore.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 = dataStore.data.map { prefs -> + prefs[pluginsEnabledKey] ?: true + } + + suspend fun setPluginsEnabled(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[pluginsEnabledKey] = enabled + } + } + + // Scraper code storage + fun getScraperCodeFile(scraperId: String): File { + return File(codeDir, "$scraperId.js") + } + + fun saveScraperCode(scraperId: String, code: String) { + getScraperCodeFile(scraperId).writeText(code) + } + + fun getScraperCode(scraperId: String): String? { + val file = getScraperCodeFile(scraperId) + return if (file.exists()) file.readText() else null + } + + fun deleteScraperCode(scraperId: String) { + getScraperCodeFile(scraperId).delete() + } + + fun clearAllScraperCode() { + codeDir.listFiles()?.forEach { it.delete() } + } + + // Per-scraper settings + suspend fun getScraperSettings(scraperId: String): Map { + val prefs = dataStore.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 = dataStore.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) + dataStore.edit { p -> + p[scraperSettingsKey] = json + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/local/TmdbSettingsDataStore.kt b/app/src/main/java/com/nuvio/tv/data/local/TmdbSettingsDataStore.kt new file mode 100644 index 00000000..7126cb3f --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/local/TmdbSettingsDataStore.kt @@ -0,0 +1,77 @@ +package com.nuvio.tv.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.nuvio.tv.domain.model.TmdbSettings +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.tmdbSettingsDataStore: DataStore by preferencesDataStore(name = "tmdb_settings") + +@Singleton +class TmdbSettingsDataStore @Inject constructor( + @ApplicationContext private val context: Context +) { + private val dataStore = context.tmdbSettingsDataStore + + private val enabledKey = booleanPreferencesKey("tmdb_enabled") + private val useArtworkKey = booleanPreferencesKey("tmdb_use_artwork") + private val useBasicInfoKey = booleanPreferencesKey("tmdb_use_basic_info") + private val useDetailsKey = booleanPreferencesKey("tmdb_use_details") + private val useCreditsKey = booleanPreferencesKey("tmdb_use_credits") + private val useProductionsKey = booleanPreferencesKey("tmdb_use_productions") + private val useNetworksKey = booleanPreferencesKey("tmdb_use_networks") + private val useEpisodesKey = booleanPreferencesKey("tmdb_use_episodes") + + val settings: Flow = dataStore.data.map { prefs -> + TmdbSettings( + enabled = prefs[enabledKey] ?: true, + useArtwork = prefs[useArtworkKey] ?: true, + useBasicInfo = prefs[useBasicInfoKey] ?: true, + useDetails = prefs[useDetailsKey] ?: true, + useCredits = prefs[useCreditsKey] ?: true, + useProductions = prefs[useProductionsKey] ?: true, + useNetworks = prefs[useNetworksKey] ?: true, + useEpisodes = prefs[useEpisodesKey] ?: true + ) + } + + suspend fun setEnabled(enabled: Boolean) { + dataStore.edit { it[enabledKey] = enabled } + } + + suspend fun setUseArtwork(enabled: Boolean) { + dataStore.edit { it[useArtworkKey] = enabled } + } + + suspend fun setUseBasicInfo(enabled: Boolean) { + dataStore.edit { it[useBasicInfoKey] = enabled } + } + + suspend fun setUseDetails(enabled: Boolean) { + dataStore.edit { it[useDetailsKey] = enabled } + } + + suspend fun setUseCredits(enabled: Boolean) { + dataStore.edit { it[useCreditsKey] = enabled } + } + + suspend fun setUseProductions(enabled: Boolean) { + dataStore.edit { it[useProductionsKey] = enabled } + } + + suspend fun setUseNetworks(enabled: Boolean) { + dataStore.edit { it[useNetworksKey] = enabled } + } + + suspend fun setUseEpisodes(enabled: Boolean) { + dataStore.edit { it[useEpisodesKey] = enabled } + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt b/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt new file mode 100644 index 00000000..f8b4ce1f --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt @@ -0,0 +1,195 @@ +package com.nuvio.tv.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.nuvio.tv.domain.model.WatchProgress +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.watchProgressDataStore: DataStore by preferencesDataStore( + name = "watch_progress_preferences" +) + +@Singleton +class WatchProgressPreferences @Inject constructor( + @ApplicationContext private val context: Context +) { + private val gson = Gson() + private val watchProgressKey = stringPreferencesKey("watch_progress_map") + + // Maximum items to keep in continue watching + private val maxItems = 50 + + /** + * Get all watch progress items, sorted by last watched (most recent first) + * For series, only returns the series-level entry (not individual episode entries) + * to avoid duplicates in continue watching. + */ + val allProgress: Flow> = context.watchProgressDataStore.data + .map { preferences -> + val json = preferences[watchProgressKey] ?: "{}" + val allItems = parseProgressMap(json) + + // Filter to keep only series-level entries (key = contentId) or movie entries + // This avoids duplicate entries where both episode-specific and series-level exist + allItems.entries + .filter { (key, progress) -> + // Keep movies (no season/episode) OR series-level entries (key matches contentId exactly) + progress.contentType == "movie" || key == progress.contentId + } + .map { it.value } + .sortedByDescending { it.lastWatched } + .toList() + } + + /** + * Get items that are in progress (not completed) + */ + val continueWatching: Flow> = allProgress.map { list -> + list.filter { it.isInProgress() } + } + + /** + * Get watch progress for a specific content item + */ + fun getProgress(contentId: String): Flow { + return context.watchProgressDataStore.data.map { preferences -> + val json = preferences[watchProgressKey] ?: "{}" + val map = parseProgressMap(json) + map[contentId] + } + } + + /** + * Get watch progress for a specific episode + */ + fun getEpisodeProgress(contentId: String, season: Int, episode: Int): Flow { + return context.watchProgressDataStore.data.map { preferences -> + val json = preferences[watchProgressKey] ?: "{}" + val map = parseProgressMap(json) + map.values.find { + it.contentId == contentId && it.season == season && it.episode == episode + } + } + } + + /** + * Get all episode progress for a series + */ + fun getAllEpisodeProgress(contentId: String): Flow, WatchProgress>> { + return context.watchProgressDataStore.data.map { preferences -> + val json = preferences[watchProgressKey] ?: "{}" + val map = parseProgressMap(json) + map.values + .filter { it.contentId == contentId && it.season != null && it.episode != null } + .associateBy { (it.season!! to it.episode!!) } + } + } + + /** + * Save or update watch progress + */ + suspend fun saveProgress(progress: WatchProgress) { + context.watchProgressDataStore.edit { preferences -> + val json = preferences[watchProgressKey] ?: "{}" + val map = parseProgressMap(json).toMutableMap() + + // Use a composite key for series episodes to track each episode separately + val key = createKey(progress) + map[key] = progress + + // Also update the series-level entry to track the latest episode + if (progress.contentType == "series" && progress.season != null && progress.episode != null) { + val seriesKey = progress.contentId + val existingSeriesProgress = map[seriesKey] + + // Update series-level progress if this is a more recent watch + if (existingSeriesProgress == null || progress.lastWatched > existingSeriesProgress.lastWatched) { + map[seriesKey] = progress.copy(videoId = progress.videoId) + } + } + + // Prune old items if exceeds max + val pruned = pruneOldItems(map) + preferences[watchProgressKey] = gson.toJson(pruned) + } + } + + /** + * Remove watch progress for a specific item + */ + suspend fun removeProgress(contentId: String, season: Int? = null, episode: Int? = null) { + context.watchProgressDataStore.edit { preferences -> + val json = preferences[watchProgressKey] ?: "{}" + val map = parseProgressMap(json).toMutableMap() + + if (season != null && episode != null) { + // Remove specific episode progress + val key = "${contentId}_s${season}e${episode}" + map.remove(key) + } else { + // Remove all progress for this content + val keysToRemove = map.keys.filter { it.startsWith(contentId) } + keysToRemove.forEach { map.remove(it) } + } + + preferences[watchProgressKey] = gson.toJson(map) + } + } + + /** + * Mark content as completed + */ + suspend fun markAsCompleted(progress: WatchProgress) { + val completedProgress = progress.copy( + position = progress.duration, + lastWatched = System.currentTimeMillis() + ) + saveProgress(completedProgress) + } + + /** + * Clear all watch progress + */ + suspend fun clearAll() { + context.watchProgressDataStore.edit { preferences -> + preferences.remove(watchProgressKey) + } + } + + private fun createKey(progress: WatchProgress): String { + return if (progress.season != null && progress.episode != null) { + "${progress.contentId}_s${progress.season}e${progress.episode}" + } else { + progress.contentId + } + } + + private fun parseProgressMap(json: String): Map { + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) ?: emptyMap() + } catch (e: Exception) { + emptyMap() + } + } + + private fun pruneOldItems(map: MutableMap): Map { + if (map.size <= maxItems) return map + + // Keep the most recently watched items + return map.entries + .sortedByDescending { it.value.lastWatched } + .take(maxItems) + .associate { it.key to it.value } + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/mapper/AddonMapper.kt b/app/src/main/java/com/nuvio/tv/data/mapper/AddonMapper.kt new file mode 100644 index 00000000..7f168dc1 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/mapper/AddonMapper.kt @@ -0,0 +1,66 @@ +package com.nuvio.tv.data.mapper + +import com.nuvio.tv.data.remote.dto.AddonManifestDto +import com.nuvio.tv.data.remote.dto.CatalogDescriptorDto +import com.nuvio.tv.domain.model.Addon +import com.nuvio.tv.domain.model.AddonResource +import com.nuvio.tv.domain.model.CatalogExtra +import com.nuvio.tv.domain.model.CatalogDescriptor +import com.nuvio.tv.domain.model.ContentType + +fun AddonManifestDto.toDomain(baseUrl: String): Addon { + return Addon( + id = id, + name = name, + version = version, + description = description, + logo = logo, + baseUrl = baseUrl, + catalogs = catalogs.map { it.toDomain() }, + types = types.map { ContentType.fromString(it) }, + resources = parseResources(resources, types) + ) +} + +fun CatalogDescriptorDto.toDomain(): CatalogDescriptor { + return CatalogDescriptor( + type = ContentType.fromString(type), + id = id, + name = name, + extra = extra.orEmpty().map { dto -> + CatalogExtra( + name = dto.name, + isRequired = dto.isRequired ?: false, + options = dto.options + ) + } + ) +} + +private fun parseResources(resources: List, defaultTypes: List): List { + return resources.mapNotNull { resource -> + when (resource) { + is String -> { + // Simple resource format: "meta", "stream", etc. + AddonResource( + name = resource, + types = defaultTypes, + idPrefixes = null + ) + } + is Map<*, *> -> { + // Complex resource format with types and idPrefixes + val name = resource["name"] as? String ?: return@mapNotNull null + val types = (resource["types"] as? List<*>)?.filterIsInstance() ?: defaultTypes + val idPrefixes = (resource["idPrefixes"] as? List<*>)?.filterIsInstance() + AddonResource( + name = name, + types = types, + idPrefixes = idPrefixes + ) + } + else -> null + } + } +} + diff --git a/app/src/main/java/com/nuvio/tv/data/mapper/CatalogMapper.kt b/app/src/main/java/com/nuvio/tv/data/mapper/CatalogMapper.kt new file mode 100644 index 00000000..f657c10a --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/mapper/CatalogMapper.kt @@ -0,0 +1,21 @@ +package com.nuvio.tv.data.mapper + +import com.nuvio.tv.data.remote.dto.MetaPreviewDto +import com.nuvio.tv.domain.model.ContentType +import com.nuvio.tv.domain.model.MetaPreview +import com.nuvio.tv.domain.model.PosterShape + +fun MetaPreviewDto.toDomain(): MetaPreview { + return MetaPreview( + id = id, + type = ContentType.fromString(type), + name = name, + poster = poster, + posterShape = PosterShape.fromString(posterShape), + background = background, + description = description, + releaseInfo = releaseInfo, + imdbRating = imdbRating?.toFloatOrNull(), + genres = genres ?: emptyList() + ) +} diff --git a/app/src/main/java/com/nuvio/tv/data/mapper/MetaMapper.kt b/app/src/main/java/com/nuvio/tv/data/mapper/MetaMapper.kt new file mode 100644 index 00000000..e7519eb4 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/mapper/MetaMapper.kt @@ -0,0 +1,85 @@ +package com.nuvio.tv.data.mapper + +import com.nuvio.tv.data.remote.dto.MetaDto +import com.nuvio.tv.data.remote.dto.MetaLinkDto +import com.nuvio.tv.data.remote.dto.VideoDto +import com.nuvio.tv.domain.model.ContentType +import com.nuvio.tv.domain.model.Meta +import com.nuvio.tv.domain.model.MetaCastMember +import com.nuvio.tv.domain.model.MetaLink +import com.nuvio.tv.domain.model.PosterShape +import com.nuvio.tv.domain.model.Video + +fun MetaDto.toDomain(): Meta { + return Meta( + id = id, + type = ContentType.fromString(type), + name = name, + poster = poster, + posterShape = PosterShape.fromString(posterShape), + background = background, + logo = logo, + description = description, + releaseInfo = releaseInfo, + imdbRating = imdbRating?.toFloatOrNull(), + genres = genres ?: emptyList(), + runtime = runtime, + director = coerceStringList(director), + writer = coerceStringList(writer).ifEmpty { coerceStringList(writers) }, + cast = coerceStringList(cast), + castMembers = appExtras?.cast + .orEmpty() + .mapNotNull { castMember -> + val name = castMember.name.trim() + if (name.isBlank()) return@mapNotNull null + MetaCastMember( + name = name, + character = castMember.character?.takeIf { it.isNotBlank() }, + photo = castMember.photo?.takeIf { it.isNotBlank() } + ) + }, + videos = videos?.map { it.toDomain() } ?: emptyList(), + productionCompanies = emptyList(), + networks = emptyList(), + country = country, + awards = awards, + language = language, + links = links?.mapNotNull { it.toDomain() } ?: emptyList() + ) +} + +private fun coerceStringList(value: Any?): List { + return when (value) { + null -> emptyList() + is String -> listOf(value) + is List<*> -> value.filterIsInstance() + is Map<*, *> -> { + // Some addons may return an object; try a couple common keys. + val name = value["name"] as? String + if (!name.isNullOrBlank()) listOf(name) else emptyList() + } + else -> emptyList() + } +} + +fun VideoDto.toDomain(): Video { + return Video( + id = id, + title = name ?: title ?: "Episode ${episode ?: number ?: 0}", + released = released, + thumbnail = thumbnail, + season = season, + episode = episode ?: number, + overview = overview ?: description + ) +} + +fun MetaLinkDto.toDomain(): MetaLink? { + return url?.let { + MetaLink( + name = name, + category = category, + url = it + ) + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/mapper/StreamMapper.kt b/app/src/main/java/com/nuvio/tv/data/mapper/StreamMapper.kt new file mode 100644 index 00000000..4e346f13 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/mapper/StreamMapper.kt @@ -0,0 +1,34 @@ +package com.nuvio.tv.data.mapper + +import com.nuvio.tv.data.remote.dto.BehaviorHintsDto +import com.nuvio.tv.data.remote.dto.ProxyHeadersDto +import com.nuvio.tv.data.remote.dto.StreamDto +import com.nuvio.tv.domain.model.ProxyHeaders +import com.nuvio.tv.domain.model.Stream +import com.nuvio.tv.domain.model.StreamBehaviorHints + +fun StreamDto.toDomain(addonName: String, addonLogo: String?): Stream = Stream( + name = name, + title = title, + description = description, + url = url, + ytId = ytId, + infoHash = infoHash, + fileIdx = fileIdx, + externalUrl = externalUrl, + behaviorHints = behaviorHints?.toDomain(), + addonName = addonName, + addonLogo = addonLogo +) + +fun BehaviorHintsDto.toDomain(): StreamBehaviorHints = StreamBehaviorHints( + notWebReady = notWebReady, + bingeGroup = bingeGroup, + countryWhitelist = countryWhitelist, + proxyHeaders = proxyHeaders?.toDomain() +) + +fun ProxyHeadersDto.toDomain(): ProxyHeaders = ProxyHeaders( + request = request, + response = response +) diff --git a/app/src/main/java/com/nuvio/tv/data/remote/api/AddonApi.kt b/app/src/main/java/com/nuvio/tv/data/remote/api/AddonApi.kt new file mode 100644 index 00000000..7c669d7d --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/remote/api/AddonApi.kt @@ -0,0 +1,24 @@ +package com.nuvio.tv.data.remote.api + +import com.nuvio.tv.data.remote.dto.AddonManifestDto +import com.nuvio.tv.data.remote.dto.CatalogResponseDto +import com.nuvio.tv.data.remote.dto.MetaResponseDto +import com.nuvio.tv.data.remote.dto.StreamResponseDto +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Url + +interface AddonApi { + + @GET + suspend fun getManifest(@Url manifestUrl: String): Response + + @GET + suspend fun getCatalog(@Url catalogUrl: String): Response + + @GET + suspend fun getMeta(@Url metaUrl: String): Response + + @GET + suspend fun getStreams(@Url streamUrl: String): Response +} diff --git a/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt b/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt new file mode 100644 index 00000000..6461ea32 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt @@ -0,0 +1,190 @@ +package com.nuvio.tv.data.remote.api + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface TmdbApi { + + @GET("find/{external_id}") + suspend fun findByExternalId( + @Path("external_id") externalId: String, + @Query("api_key") apiKey: String, + @Query("external_source") externalSource: String = "imdb_id" + ): Response + + @GET("movie/{movie_id}/external_ids") + suspend fun getMovieExternalIds( + @Path("movie_id") movieId: Int, + @Query("api_key") apiKey: String + ): Response + + @GET("tv/{tv_id}/external_ids") + suspend fun getTvExternalIds( + @Path("tv_id") tvId: Int, + @Query("api_key") apiKey: String + ): Response + + @GET("movie/{movie_id}") + suspend fun getMovieDetails( + @Path("movie_id") movieId: Int, + @Query("api_key") apiKey: String + ): Response + + @GET("tv/{tv_id}") + suspend fun getTvDetails( + @Path("tv_id") tvId: Int, + @Query("api_key") apiKey: String + ): Response + + @GET("movie/{movie_id}/credits") + suspend fun getMovieCredits( + @Path("movie_id") movieId: Int, + @Query("api_key") apiKey: String + ): Response + + @GET("tv/{tv_id}/credits") + suspend fun getTvCredits( + @Path("tv_id") tvId: Int, + @Query("api_key") apiKey: String + ): Response + + @GET("movie/{movie_id}/images") + suspend fun getMovieImages( + @Path("movie_id") movieId: Int, + @Query("api_key") apiKey: String, + @Query("include_image_language") includeImageLanguage: String = "en,null" + ): Response + + @GET("tv/{tv_id}/images") + suspend fun getTvImages( + @Path("tv_id") tvId: Int, + @Query("api_key") apiKey: String, + @Query("include_image_language") includeImageLanguage: String = "en,null" + ): Response + + @GET("tv/{tv_id}/season/{season_number}") + suspend fun getTvSeasonDetails( + @Path("tv_id") tvId: Int, + @Path("season_number") seasonNumber: Int, + @Query("api_key") apiKey: String + ): Response +} + +@JsonClass(generateAdapter = true) +data class TmdbFindResponse( + @Json(name = "movie_results") val movieResults: List? = null, + @Json(name = "tv_results") val tvResults: List? = null, + @Json(name = "tv_episode_results") val tvEpisodeResults: List? = null, + @Json(name = "tv_season_results") val tvSeasonResults: List? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbFindResult( + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String? = null, + @Json(name = "name") val name: String? = null, + @Json(name = "media_type") val mediaType: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbExternalIdsResponse( + @Json(name = "id") val id: Int, + @Json(name = "imdb_id") val imdbId: String? = null, + @Json(name = "tvdb_id") val tvdbId: Int? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbDetailsResponse( + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String? = null, + @Json(name = "name") val name: String? = null, + @Json(name = "overview") val overview: String? = null, + @Json(name = "genres") val genres: List? = null, + @Json(name = "release_date") val releaseDate: String? = null, + @Json(name = "first_air_date") val firstAirDate: String? = null, + @Json(name = "runtime") val runtime: Int? = null, + @Json(name = "episode_run_time") val episodeRunTime: List? = null, + @Json(name = "vote_average") val voteAverage: Double? = null, + @Json(name = "production_companies") val productionCompanies: List? = null, + @Json(name = "networks") val networks: List? = null, + @Json(name = "production_countries") val productionCountries: List? = null, + @Json(name = "origin_country") val originCountry: List? = null, + @Json(name = "original_language") val originalLanguage: String? = null, + @Json(name = "backdrop_path") val backdropPath: String? = null, + @Json(name = "poster_path") val posterPath: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbGenre( + @Json(name = "id") val id: Int, + @Json(name = "name") val name: String +) + +@JsonClass(generateAdapter = true) +data class TmdbCompany( + @Json(name = "name") val name: String? = null, + @Json(name = "logo_path") val logoPath: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbNetwork( + @Json(name = "name") val name: String? = null, + @Json(name = "logo_path") val logoPath: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbCreditsResponse( + @Json(name = "cast") val cast: List? = null, + @Json(name = "crew") val crew: List? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbCastMember( + @Json(name = "name") val name: String? = null, + @Json(name = "character") val character: String? = null, + @Json(name = "profile_path") val profilePath: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbCrewMember( + @Json(name = "name") val name: String? = null, + @Json(name = "job") val job: String? = null, + @Json(name = "department") val department: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbImagesResponse( + @Json(name = "logos") val logos: List? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbImage( + @Json(name = "file_path") val filePath: String? = null, + @Json(name = "iso_639_1") val iso6391: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbCountry( + @Json(name = "iso_3166_1") val iso31661: String? = null, + @Json(name = "name") val name: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbSeasonResponse( + @Json(name = "season_number") val seasonNumber: Int? = null, + @Json(name = "episodes") val episodes: List? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbEpisode( + @Json(name = "episode_number") val episodeNumber: Int? = null, + @Json(name = "name") val name: String? = null, + @Json(name = "overview") val overview: String? = null, + @Json(name = "still_path") val stillPath: String? = null, + @Json(name = "air_date") val airDate: String? = null, + @Json(name = "runtime") val runtime: Int? = null +) diff --git a/app/src/main/java/com/nuvio/tv/data/remote/dto/AddonManifestDto.kt b/app/src/main/java/com/nuvio/tv/data/remote/dto/AddonManifestDto.kt new file mode 100644 index 00000000..f6644204 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/remote/dto/AddonManifestDto.kt @@ -0,0 +1,32 @@ +package com.nuvio.tv.data.remote.dto + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AddonManifestDto( + @Json(name = "id") val id: String, + @Json(name = "name") val name: String, + @Json(name = "version") val version: String, + @Json(name = "description") val description: String? = null, + @Json(name = "logo") val logo: String? = null, + @Json(name = "background") val background: String? = null, + @Json(name = "catalogs") val catalogs: List = emptyList(), + @Json(name = "resources") val resources: List = emptyList(), + @Json(name = "types") val types: List = emptyList() +) + +@JsonClass(generateAdapter = true) +data class CatalogDescriptorDto( + @Json(name = "type") val type: String, + @Json(name = "id") val id: String, + @Json(name = "name") val name: String, + @Json(name = "extra") val extra: List? = null +) + +@JsonClass(generateAdapter = true) +data class ExtraDto( + @Json(name = "name") val name: String, + @Json(name = "isRequired") val isRequired: Boolean? = false, + @Json(name = "options") val options: List? = null +) diff --git a/app/src/main/java/com/nuvio/tv/data/remote/dto/CatalogResponseDto.kt b/app/src/main/java/com/nuvio/tv/data/remote/dto/CatalogResponseDto.kt new file mode 100644 index 00000000..cfc4284a --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/remote/dto/CatalogResponseDto.kt @@ -0,0 +1,25 @@ +package com.nuvio.tv.data.remote.dto + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CatalogResponseDto( + @Json(name = "metas") val metas: List = emptyList() +) + +@JsonClass(generateAdapter = true) +data class MetaPreviewDto( + @Json(name = "id") val id: String, + @Json(name = "type") val type: String, + @Json(name = "name") val name: String, + @Json(name = "poster") val poster: String? = null, + @Json(name = "posterShape") val posterShape: String? = null, + @Json(name = "background") val background: String? = null, + @Json(name = "logo") val logo: String? = null, + @Json(name = "description") val description: String? = null, + @Json(name = "releaseInfo") val releaseInfo: String? = null, + @Json(name = "imdbRating") val imdbRating: String? = null, + @Json(name = "genres") val genres: List? = null, + @Json(name = "runtime") val runtime: String? = null +) diff --git a/app/src/main/java/com/nuvio/tv/data/remote/dto/MetaResponseDto.kt b/app/src/main/java/com/nuvio/tv/data/remote/dto/MetaResponseDto.kt new file mode 100644 index 00000000..43786622 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/remote/dto/MetaResponseDto.kt @@ -0,0 +1,70 @@ +package com.nuvio.tv.data.remote.dto + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class MetaResponseDto( + @Json(name = "meta") val meta: MetaDto? = null +) + +@JsonClass(generateAdapter = true) +data class MetaDto( + @Json(name = "id") val id: String, + @Json(name = "type") val type: String, + @Json(name = "name") val name: String, + @Json(name = "poster") val poster: String? = null, + @Json(name = "posterShape") val posterShape: String? = null, + @Json(name = "background") val background: String? = null, + @Json(name = "logo") val logo: String? = null, + @Json(name = "description") val description: String? = null, + @Json(name = "releaseInfo") val releaseInfo: String? = null, + @Json(name = "imdbRating") val imdbRating: String? = null, + @Json(name = "genres") val genres: List? = null, + @Json(name = "runtime") val runtime: String? = null, + // Stremio addons are inconsistent here (string vs list). Keep it tolerant. + @Json(name = "director") val director: Any? = null, + // Addons are inconsistent: may be `writer` (string/list) or `writers`. + @Json(name = "writer") val writer: Any? = null, + @Json(name = "writers") val writers: Any? = null, + @Json(name = "cast") val cast: Any? = null, + @Json(name = "videos") val videos: List? = null, + @Json(name = "country") val country: String? = null, + @Json(name = "awards") val awards: String? = null, + @Json(name = "language") val language: String? = null, + @Json(name = "links") val links: List? = null, + @Json(name = "app_extras") val appExtras: AppExtrasDto? = null +) + +@JsonClass(generateAdapter = true) +data class AppExtrasDto( + @Json(name = "cast") val cast: List? = null +) + +@JsonClass(generateAdapter = true) +data class AppExtrasCastMemberDto( + @Json(name = "name") val name: String, + @Json(name = "character") val character: String? = null, + @Json(name = "photo") val photo: String? = null +) + +@JsonClass(generateAdapter = true) +data class VideoDto( + @Json(name = "id") val id: String, + @Json(name = "name") val name: String? = null, + @Json(name = "title") val title: String? = null, + @Json(name = "released") val released: String? = null, + @Json(name = "thumbnail") val thumbnail: String? = null, + @Json(name = "season") val season: Int? = null, + @Json(name = "episode") val episode: Int? = null, + @Json(name = "number") val number: Int? = null, + @Json(name = "overview") val overview: String? = null, + @Json(name = "description") val description: String? = null +) + +@JsonClass(generateAdapter = true) +data class MetaLinkDto( + @Json(name = "name") val name: String, + @Json(name = "category") val category: String, + @Json(name = "url") val url: String? = null +) diff --git a/app/src/main/java/com/nuvio/tv/data/remote/dto/StreamResponseDto.kt b/app/src/main/java/com/nuvio/tv/data/remote/dto/StreamResponseDto.kt new file mode 100644 index 00000000..2c595edb --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/remote/dto/StreamResponseDto.kt @@ -0,0 +1,48 @@ +package com.nuvio.tv.data.remote.dto + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class StreamResponseDto( + @Json(name = "streams") val streams: List? = null +) + +@JsonClass(generateAdapter = true) +data class StreamDto( + @Json(name = "name") val name: String? = null, + @Json(name = "title") val title: String? = null, + @Json(name = "description") val description: String? = null, + @Json(name = "url") val url: String? = null, + @Json(name = "ytId") val ytId: String? = null, + @Json(name = "infoHash") val infoHash: String? = null, + @Json(name = "fileIdx") val fileIdx: Int? = null, + @Json(name = "externalUrl") val externalUrl: String? = null, + @Json(name = "behaviorHints") val behaviorHints: BehaviorHintsDto? = null, + @Json(name = "sources") val sources: List? = null, + @Json(name = "subtitles") val subtitles: List? = null +) + +@JsonClass(generateAdapter = true) +data class BehaviorHintsDto( + @Json(name = "notWebReady") val notWebReady: Boolean? = null, + @Json(name = "bingeGroup") val bingeGroup: String? = null, + @Json(name = "countryWhitelist") val countryWhitelist: List? = null, + @Json(name = "proxyHeaders") val proxyHeaders: ProxyHeadersDto? = null, + @Json(name = "videoHash") val videoHash: String? = null, + @Json(name = "videoSize") val videoSize: Long? = null, + @Json(name = "filename") val filename: String? = null +) + +@JsonClass(generateAdapter = true) +data class ProxyHeadersDto( + @Json(name = "request") val request: Map? = null, + @Json(name = "response") val response: Map? = null +) + +@JsonClass(generateAdapter = true) +data class SubtitleDto( + @Json(name = "id") val id: String? = null, + @Json(name = "url") val url: String, + @Json(name = "lang") val lang: String +) diff --git a/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt new file mode 100644 index 00000000..8dd36797 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt @@ -0,0 +1,53 @@ +package com.nuvio.tv.data.repository + +import com.nuvio.tv.core.network.NetworkResult +import com.nuvio.tv.core.network.safeApiCall +import com.nuvio.tv.data.local.AddonPreferences +import com.nuvio.tv.data.mapper.toDomain +import com.nuvio.tv.data.remote.api.AddonApi +import com.nuvio.tv.domain.model.Addon +import com.nuvio.tv.domain.repository.AddonRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class AddonRepositoryImpl @Inject constructor( + private val api: AddonApi, + private val preferences: AddonPreferences +) : AddonRepository { + + override fun getInstalledAddons(): Flow> = + preferences.installedAddonUrls.map { urls -> + val addons = mutableListOf() + + for (url in urls) { + when (val result = fetchAddon(url)) { + is NetworkResult.Success -> addons.add(result.data) + else -> { /* Skip failed addons */ } + } + } + + addons + } + + override suspend fun fetchAddon(baseUrl: String): NetworkResult { + val cleanBaseUrl = baseUrl.trimEnd('/') + val manifestUrl = "$cleanBaseUrl/manifest.json" + + return when (val result = safeApiCall { api.getManifest(manifestUrl) }) { + is NetworkResult.Success -> { + NetworkResult.Success(result.data.toDomain(cleanBaseUrl)) + } + is NetworkResult.Error -> result + NetworkResult.Loading -> NetworkResult.Loading + } + } + + override suspend fun addAddon(url: String) { + preferences.addAddon(url) + } + + override suspend fun removeAddon(url: String) { + preferences.removeAddon(url) + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/repository/CatalogRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/CatalogRepositoryImpl.kt new file mode 100644 index 00000000..0d1d232b --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/repository/CatalogRepositoryImpl.kt @@ -0,0 +1,90 @@ +package com.nuvio.tv.data.repository + +import com.nuvio.tv.core.network.NetworkResult +import com.nuvio.tv.core.network.safeApiCall +import com.nuvio.tv.data.mapper.toDomain +import com.nuvio.tv.data.remote.api.AddonApi +import com.nuvio.tv.domain.model.CatalogRow +import com.nuvio.tv.domain.model.ContentType +import com.nuvio.tv.domain.repository.CatalogRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.net.URLEncoder +import javax.inject.Inject + +class CatalogRepositoryImpl @Inject constructor( + private val api: AddonApi +) : CatalogRepository { + + override fun getCatalog( + addonBaseUrl: String, + addonId: String, + addonName: String, + catalogId: String, + catalogName: String, + type: String, + skip: Int, + extraArgs: Map + ): Flow> = flow { + emit(NetworkResult.Loading) + + val url = buildCatalogUrl(addonBaseUrl, type, catalogId, skip, extraArgs) + + when (val result = safeApiCall { api.getCatalog(url) }) { + is NetworkResult.Success -> { + val items = result.data.metas.map { it.toDomain() } + val catalogRow = CatalogRow( + addonId = addonId, + addonName = addonName, + addonBaseUrl = addonBaseUrl, + catalogId = catalogId, + catalogName = catalogName, + type = ContentType.fromString(type), + items = items, + isLoading = false, + hasMore = items.size >= 100, + currentPage = skip / 100 + ) + emit(NetworkResult.Success(catalogRow)) + } + is NetworkResult.Error -> emit(result) + NetworkResult.Loading -> { /* Already emitted */ } + } + } + + private fun buildCatalogUrl( + baseUrl: String, + type: String, + catalogId: String, + skip: Int, + extraArgs: Map + ): String { + val cleanBaseUrl = baseUrl.trimEnd('/') + + if (extraArgs.isEmpty()) { + return if (skip > 0) { + "$cleanBaseUrl/catalog/$type/$catalogId/skip=$skip.json" + } else { + "$cleanBaseUrl/catalog/$type/$catalogId.json" + } + } + + val allArgs = LinkedHashMap() + allArgs.putAll(extraArgs) + + // For Stremio catalogs, pagination is controlled by `skip` inside extraArgs. + if (!allArgs.containsKey("skip") && (skip > 0 || allArgs.containsKey("search"))) { + allArgs["skip"] = skip.toString() + } + + val encodedArgs = allArgs.entries.joinToString("&") { (key, value) -> + "${encodeArg(key)}=${encodeArg(value)}" + } + + return "$cleanBaseUrl/catalog/$type/$catalogId/$encodedArgs.json" + } + + private fun encodeArg(value: String): String { + return URLEncoder.encode(value, "UTF-8").replace("+", "%20") + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt new file mode 100644 index 00000000..f61e343f --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt @@ -0,0 +1,106 @@ +package com.nuvio.tv.data.repository + +import com.nuvio.tv.core.network.NetworkResult +import com.nuvio.tv.core.network.safeApiCall +import com.nuvio.tv.data.mapper.toDomain +import com.nuvio.tv.data.remote.api.AddonApi +import com.nuvio.tv.domain.model.Meta +import com.nuvio.tv.domain.repository.AddonRepository +import com.nuvio.tv.domain.repository.MetaRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class MetaRepositoryImpl @Inject constructor( + private val api: AddonApi, + private val addonRepository: AddonRepository +) : MetaRepository { + + override fun getMeta( + addonBaseUrl: String, + type: String, + id: String + ): Flow> = flow { + emit(NetworkResult.Loading) + + val url = buildMetaUrl(addonBaseUrl, type, id) + + when (val result = safeApiCall { api.getMeta(url) }) { + is NetworkResult.Success -> { + val metaDto = result.data.meta + if (metaDto != null) { + emit(NetworkResult.Success(metaDto.toDomain())) + } else { + emit(NetworkResult.Error("Meta not found")) + } + } + is NetworkResult.Error -> emit(result) + NetworkResult.Loading -> { /* Already emitted */ } + } + } + + override fun getMetaFromAllAddons( + type: String, + id: String + ): Flow> = flow { + emit(NetworkResult.Loading) + + val addons = addonRepository.getInstalledAddons().first() + + // Find addons that support meta resource for this type + // Resources can be simple strings like "meta" or objects with name/types + val metaAddons = addons.filter { addon -> + addon.resources.any { resource -> + resource.name == "meta" && + (resource.types.isEmpty() || resource.types.contains(type)) + } + } + + if (metaAddons.isEmpty()) { + // Fallback: try all addons that have the type in their supported types + val fallbackAddons = addons.filter { addon -> + addon.types.any { it.toApiString() == type } + } + + for (addon in fallbackAddons) { + val url = buildMetaUrl(addon.baseUrl, type, id) + when (val result = safeApiCall { api.getMeta(url) }) { + is NetworkResult.Success -> { + val metaDto = result.data.meta + if (metaDto != null) { + emit(NetworkResult.Success(metaDto.toDomain())) + return@flow + } + } + else -> { /* Try next addon */ } + } + } + + emit(NetworkResult.Error("No addons support meta for type: $type")) + return@flow + } + + // Try each addon until we find meta + for (addon in metaAddons) { + val url = buildMetaUrl(addon.baseUrl, type, id) + when (val result = safeApiCall { api.getMeta(url) }) { + is NetworkResult.Success -> { + val metaDto = result.data.meta + if (metaDto != null) { + emit(NetworkResult.Success(metaDto.toDomain())) + return@flow + } + } + else -> { /* Try next addon */ } + } + } + + emit(NetworkResult.Error("Meta not found in any addon")) + } + + private fun buildMetaUrl(baseUrl: String, type: String, id: String): String { + val cleanBaseUrl = baseUrl.trimEnd('/') + return "$cleanBaseUrl/meta/$type/$id.json" + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/repository/StreamRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/StreamRepositoryImpl.kt new file mode 100644 index 00000000..4bb13e14 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/repository/StreamRepositoryImpl.kt @@ -0,0 +1,281 @@ +package com.nuvio.tv.data.repository + +import android.util.Log +import com.nuvio.tv.core.network.NetworkResult +import com.nuvio.tv.core.network.safeApiCall +import com.nuvio.tv.core.plugin.PluginManager +import com.nuvio.tv.core.tmdb.TmdbService +import com.nuvio.tv.data.mapper.toDomain +import com.nuvio.tv.data.remote.api.AddonApi +import com.nuvio.tv.domain.model.Addon +import com.nuvio.tv.domain.model.AddonStreams +import com.nuvio.tv.domain.model.ProxyHeaders +import com.nuvio.tv.domain.model.Stream +import com.nuvio.tv.domain.model.StreamBehaviorHints +import com.nuvio.tv.domain.repository.AddonRepository +import com.nuvio.tv.domain.repository.StreamRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "StreamRepositoryImpl" + +class StreamRepositoryImpl @Inject constructor( + private val api: AddonApi, + private val addonRepository: AddonRepository, + private val pluginManager: PluginManager, + private val tmdbService: TmdbService +) : StreamRepository { + + override fun getStreamsFromAllAddons( + type: String, + videoId: String, + season: Int?, + episode: Int? + ): Flow>> = flow { + emit(NetworkResult.Loading) + + try { + val addons = addonRepository.getInstalledAddons().first() + + // Filter addons that support streams for this type + val streamAddons = addons.filter { addon -> + addon.supportsStreamResource(type) + } + + // Convert IMDB ID to TMDB ID if needed for plugins + val tmdbId = tmdbService.ensureTmdbId(videoId, type) + Log.d(TAG, "Video ID: $videoId -> TMDB ID: $tmdbId (type: $type)") + + // Accumulate results as they arrive + val accumulatedResults = mutableListOf() + + coroutineScope { + // Channel to receive results as they complete + val resultChannel = Channel(Channel.UNLIMITED) + + // Track number of pending jobs + val totalJobs = streamAddons.size + (if (tmdbId != null) 1 else 0) + var completedJobs = 0 + + // Launch addon jobs + streamAddons.forEach { addon -> + launch { + try { + val streamsResult = getStreamsFromAddon(addon.baseUrl, type, videoId) + when (streamsResult) { + is NetworkResult.Success -> { + if (streamsResult.data.isNotEmpty()) { + resultChannel.send( + AddonStreams( + addonName = addon.name, + addonLogo = addon.logo, + streams = streamsResult.data + ) + ) + } + } + else -> { /* No streams */ } + } + } catch (e: Exception) { + Log.e(TAG, "Addon ${addon.name} failed: ${e.message}") + } finally { + completedJobs++ + if (completedJobs >= totalJobs) { + resultChannel.close() + } + } + } + } + + // Launch plugin jobs if we have TMDB ID - each scraper sends its own result + if (tmdbId != null) { + launch { + try { + // Stream plugins individually + streamLocalPlugins(tmdbId, type, season, episode, resultChannel) { + completedJobs++ + if (completedJobs >= totalJobs) { + resultChannel.close() + } + } + } catch (e: Exception) { + Log.e(TAG, "Plugin execution failed: ${e.message}") + completedJobs++ + if (completedJobs >= totalJobs) { + resultChannel.close() + } + } + } + } + + // Handle case where there are no jobs + if (totalJobs == 0) { + resultChannel.close() + } + + // Emit results as they arrive + for (result in resultChannel) { + accumulatedResults.add(result) + emit(NetworkResult.Success(accumulatedResults.toList())) + Log.d(TAG, "Emitted ${accumulatedResults.size} addon(s), latest: ${result.addonName} with ${result.streams.size} streams") + } + } + + // Emit final result (even if empty) + if (accumulatedResults.isEmpty()) { + emit(NetworkResult.Success(emptyList())) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch streams: ${e.message}", e) + emit(NetworkResult.Error(e.message ?: "Failed to fetch streams")) + } + } + + /** + * Stream local plugin results - each scraper sends results individually + */ + private suspend fun streamLocalPlugins( + tmdbId: String, + type: String, + season: Int?, + episode: Int?, + resultChannel: Channel, + onComplete: () -> Unit + ) { + // Check if plugins are enabled + if (!pluginManager.pluginsEnabled.first()) { + Log.d(TAG, "Plugins are disabled") + onComplete() + return + } + + // Normalize media type for plugins + val mediaType = when (type.lowercase()) { + "series", "tv", "show" -> "tv" + else -> type.lowercase() + } + + Log.d(TAG, "Streaming plugins for TMDB: $tmdbId, type: $mediaType") + + try { + // Collect streaming results from each scraper + pluginManager.executeScrapersStreaming( + tmdbId = tmdbId, + mediaType = mediaType, + season = season, + episode = episode + ).collect { (scraperName, results) -> + if (results.isNotEmpty()) { + val addonStreams = AddonStreams( + addonName = scraperName, + addonLogo = null, + streams = results.map { result -> + val baseTitle = result.title.takeIf { it.isNotBlank() } + val baseName = result.name?.takeIf { it.isNotBlank() } + val quality = result.quality?.takeIf { it.isNotBlank() } + + val displayTitle = buildString { + append(baseTitle ?: baseName ?: scraperName) + if (!quality.isNullOrBlank() && !(baseTitle ?: "").contains(quality)) { + append(" ").append(quality) + } + }.takeIf { it.isNotBlank() } + + val displayName = buildString { + append(baseName ?: baseTitle ?: scraperName) + if (!quality.isNullOrBlank() && !(baseName ?: "").contains(quality)) { + append(" - ").append(quality) + } + }.takeIf { it.isNotBlank() } + + Stream( + name = displayName, + title = displayTitle, + url = result.url, + addonName = scraperName, + addonLogo = null, + description = buildDescription(result), + behaviorHints = result.headers?.let { headers -> + StreamBehaviorHints( + notWebReady = null, + bingeGroup = null, + countryWhitelist = null, + proxyHeaders = ProxyHeaders(request = headers, response = null) + ) + }, + infoHash = result.infoHash, + fileIdx = null, + ytId = null, + externalUrl = null + ) + } + ) + resultChannel.send(addonStreams) + Log.d(TAG, "Streamed ${results.size} results from $scraperName") + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to stream plugins: ${e.message}", e) + } finally { + onComplete() + } + } + + /** + * Build a description string from scraper result + */ + private fun buildDescription(result: com.nuvio.tv.domain.model.LocalScraperResult): String? { + val parts = mutableListOf() + result.quality?.let { parts.add(it) } + result.size?.let { parts.add(it) } + result.language?.let { parts.add(it) } + return if (parts.isNotEmpty()) parts.joinToString(" • ") else null + } + + override suspend fun getStreamsFromAddon( + baseUrl: String, + type: String, + videoId: String + ): NetworkResult> { + val cleanBaseUrl = baseUrl.trimEnd('/') + val streamUrl = "$cleanBaseUrl/stream/$type/$videoId.json" + + // First, get addon info for name and logo + val addonResult = addonRepository.fetchAddon(baseUrl) + val addonName = when (addonResult) { + is NetworkResult.Success -> addonResult.data.name + else -> "Unknown" + } + val addonLogo = when (addonResult) { + is NetworkResult.Success -> addonResult.data.logo + else -> null + } + + return when (val result = safeApiCall { api.getStreams(streamUrl) }) { + is NetworkResult.Success -> { + val streams = result.data.streams?.map { + it.toDomain(addonName, addonLogo) + } ?: emptyList() + NetworkResult.Success(streams) + } + is NetworkResult.Error -> result + NetworkResult.Loading -> NetworkResult.Loading + } + } + + /** + * Check if addon supports stream resource for the given type + */ + private fun Addon.supportsStreamResource(type: String): Boolean { + return resources.any { resource -> + resource.name == "stream" && + (resource.types.isEmpty() || resource.types.contains(type)) + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt new file mode 100644 index 00000000..cf83b433 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt @@ -0,0 +1,48 @@ +package com.nuvio.tv.data.repository + +import com.nuvio.tv.data.local.WatchProgressPreferences +import com.nuvio.tv.domain.model.WatchProgress +import com.nuvio.tv.domain.repository.WatchProgressRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WatchProgressRepositoryImpl @Inject constructor( + private val watchProgressPreferences: WatchProgressPreferences +) : WatchProgressRepository { + + override val allProgress: Flow> + get() = watchProgressPreferences.allProgress + + override val continueWatching: Flow> + get() = watchProgressPreferences.continueWatching + + override fun getProgress(contentId: String): Flow { + return watchProgressPreferences.getProgress(contentId) + } + + override fun getEpisodeProgress(contentId: String, season: Int, episode: Int): Flow { + return watchProgressPreferences.getEpisodeProgress(contentId, season, episode) + } + + override fun getAllEpisodeProgress(contentId: String): Flow, WatchProgress>> { + return watchProgressPreferences.getAllEpisodeProgress(contentId) + } + + override suspend fun saveProgress(progress: WatchProgress) { + watchProgressPreferences.saveProgress(progress) + } + + override suspend fun removeProgress(contentId: String, season: Int?, episode: Int?) { + watchProgressPreferences.removeProgress(contentId, season, episode) + } + + override suspend fun markAsCompleted(progress: WatchProgress) { + watchProgressPreferences.markAsCompleted(progress) + } + + override suspend fun clearAll() { + watchProgressPreferences.clearAll() + } +} diff --git a/app/src/main/java/com/nuvio/tv/di/PluginModule.kt b/app/src/main/java/com/nuvio/tv/di/PluginModule.kt new file mode 100644 index 00000000..be5e8e8a --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/di/PluginModule.kt @@ -0,0 +1,30 @@ +package com.nuvio.tv.di + +import com.nuvio.tv.core.plugin.PluginManager +import com.nuvio.tv.core.plugin.PluginRuntime +import com.nuvio.tv.data.local.PluginDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PluginModule { + + @Provides + @Singleton + fun providePluginRuntime(): PluginRuntime { + return PluginRuntime() + } + + @Provides + @Singleton + fun providePluginManager( + dataStore: PluginDataStore, + runtime: PluginRuntime + ): PluginManager { + return PluginManager(dataStore, runtime) + } +} diff --git a/app/src/main/java/com/nuvio/tv/domain/model/Addon.kt b/app/src/main/java/com/nuvio/tv/domain/model/Addon.kt new file mode 100644 index 00000000..35ca41b2 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/domain/model/Addon.kt @@ -0,0 +1,33 @@ +package com.nuvio.tv.domain.model + +data class Addon( + val id: String, + val name: String, + val version: String, + val description: String?, + val logo: String?, + val baseUrl: String, + val catalogs: List, + val types: List, + val resources: List +) + +data class CatalogDescriptor( + val type: ContentType, + val id: String, + val name: String, + val extra: List = emptyList() +) + +data class CatalogExtra( + val name: String, + val isRequired: Boolean = false, + val options: List? = null +) + +data class AddonResource( + val name: String, + val types: List, + val idPrefixes: List? +) + diff --git a/app/src/main/java/com/nuvio/tv/domain/model/CatalogRow.kt b/app/src/main/java/com/nuvio/tv/domain/model/CatalogRow.kt new file mode 100644 index 00000000..b86c1d57 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/domain/model/CatalogRow.kt @@ -0,0 +1,14 @@ +package com.nuvio.tv.domain.model + +data class CatalogRow( + val addonId: String, + val addonName: String, + val addonBaseUrl: String, + val catalogId: String, + val catalogName: String, + val type: ContentType, + val items: List, + val isLoading: Boolean = false, + val hasMore: Boolean = true, + val currentPage: Int = 0 +) diff --git a/app/src/main/java/com/nuvio/tv/domain/model/ContentType.kt b/app/src/main/java/com/nuvio/tv/domain/model/ContentType.kt new file mode 100644 index 00000000..61b628ca --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/domain/model/ContentType.kt @@ -0,0 +1,27 @@ +package com.nuvio.tv.domain.model + +enum class ContentType { + MOVIE, + SERIES, + CHANNEL, + TV, + UNKNOWN; + + companion object { + fun fromString(value: String): ContentType = when (value.lowercase()) { + "movie" -> MOVIE + "series" -> SERIES + "channel" -> CHANNEL + "tv" -> TV + else -> UNKNOWN + } + } + + fun toApiString(): String = when (this) { + MOVIE -> "movie" + SERIES -> "series" + CHANNEL -> "channel" + TV -> "tv" + UNKNOWN -> "movie" + } +} diff --git a/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt b/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt new file mode 100644 index 00000000..7f2251a4 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt @@ -0,0 +1,55 @@ +package com.nuvio.tv.domain.model + +data class Meta( + val id: String, + val type: ContentType, + val name: String, + val poster: String?, + val posterShape: PosterShape, + val background: String?, + val logo: String?, + val description: String?, + val releaseInfo: String?, + val imdbRating: Float?, + val genres: List, + val runtime: String?, + val director: List, + val writer: List = emptyList(), + val cast: List, + val castMembers: List = emptyList(), + val videos: List