diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b1b36fd33..b6b1820c8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -418,7 +418,7 @@ kotlin { implementation(libs.androidx.media3.extractor) implementation(libs.mpv.android.lib) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") - implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar")))) + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "lib-*.aar")))) if (androidDistribution == "full") { implementation(files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) implementation(libs.ksoup) @@ -436,6 +436,9 @@ kotlin { exclude(group = "org.jetbrains.skiko", module = "skiko") } implementation("dev.chrisbanes.haze:haze:1.7.2") + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.cio) + implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) diff --git a/composeApp/libs/tdlib.jar b/composeApp/libs/tdlib.jar new file mode 100644 index 000000000..66350bab2 Binary files /dev/null and b/composeApp/libs/tdlib.jar differ diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index e06a3b996..36677f5f6 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -62,3 +62,9 @@ -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** + +# TDLib — preserve all JNI callback classes +# The native layer calls back into Java by exact name; obfuscation breaks it. +-keep class org.drinkless.tdlib.** { *; } +-dontwarn org.drinkless.tdlib.** + diff --git a/composeApp/src/androidMain/jniLibs/arm64-v8a/libtdjni.so b/composeApp/src/androidMain/jniLibs/arm64-v8a/libtdjni.so new file mode 100644 index 000000000..731f9fa02 Binary files /dev/null and b/composeApp/src/androidMain/jniLibs/arm64-v8a/libtdjni.so differ diff --git a/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtdjni.so b/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtdjni.so new file mode 100644 index 000000000..c379508a9 Binary files /dev/null and b/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtdjni.so differ diff --git a/composeApp/src/androidMain/jniLibs/x86/libtdjni.so b/composeApp/src/androidMain/jniLibs/x86/libtdjni.so new file mode 100644 index 000000000..d38923434 Binary files /dev/null and b/composeApp/src/androidMain/jniLibs/x86/libtdjni.so differ diff --git a/composeApp/src/androidMain/jniLibs/x86_64/libtdjni.so b/composeApp/src/androidMain/jniLibs/x86_64/libtdjni.so new file mode 100644 index 000000000..2b6315a7f Binary files /dev/null and b/composeApp/src/androidMain/jniLibs/x86_64/libtdjni.so differ diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index d13a24530..67a67a3f1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -121,7 +121,9 @@ class MainActivity : AppCompatActivity() { DownloadsLiveStatusPlatform.initialize(applicationContext) AndroidAppUpdaterPlatform.initialize(applicationContext) PlatformLocalAccountDataCleaner.initialize(applicationContext) + com.nuvio.app.features.telegram.TelegramRepository.initialize(applicationContext) EpisodeReleaseNotificationPlatform.initialize(applicationContext) + EpisodeReleaseNotificationPlatform.bindActivity(this) handleIncomingAppIntent(intent) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/TelegramSettingsPage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/TelegramSettingsPage.android.kt new file mode 100644 index 000000000..22b5370e4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/TelegramSettingsPage.android.kt @@ -0,0 +1,322 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.telegram.TelegramAuthState +import com.nuvio.app.features.telegram.TelegramRepository + +@Composable +internal actual fun TelegramSettingsPage( + isTablet: Boolean, + onBack: () -> Unit, +) { + val authState by TelegramRepository.authState.collectAsState() + var phoneInput by remember { mutableStateOf("") } + var codeInput by remember { mutableStateOf("") } + var passwordInput by remember { mutableStateOf("") } + var cacheSize by remember { mutableStateOf(TelegramRepository.getCacheSize()) } + + androidx.compose.runtime.LaunchedEffect(Unit) { + TelegramRepository.startAuth() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Telegram Integration", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = "Connect your Telegram account to scan chats and stream movies & shows directly.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + when (val state = authState) { + is TelegramAuthState.Idle, is TelegramAuthState.WaitPhone -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Log In with Phone Number", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Enter your phone number including country code (e.g. +1234567890):", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = phoneInput, + onValueChange = { phoneInput = it }, + label = { Text("Phone Number") }, + placeholder = { Text("+1234567890") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { + if (phoneInput.isNotBlank()) TelegramRepository.submitPhone(phoneInput) + }), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + if (phoneInput.isNotBlank()) { + TelegramRepository.startAuth() + TelegramRepository.submitPhone(phoneInput) + } + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Send Verification Code") + } + } + } + } + + is TelegramAuthState.Initializing -> { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.padding(end = 16.dp)) + Text("Initializing Telegram Client...") + } + } + } + + is TelegramAuthState.WaitCode -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Enter Verification Code", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Enter the ${state.codeLength}-digit OTP code sent to your Telegram app:", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = codeInput, + onValueChange = { codeInput = it }, + label = { Text("OTP Code") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { + if (codeInput.isNotBlank()) TelegramRepository.submitCode(codeInput) + }), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + if (codeInput.isNotBlank()) TelegramRepository.submitCode(codeInput) + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Verify Code") + } + } + } + } + + is TelegramAuthState.WaitPassword -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Two-Step Verification (2FA)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Enter your Telegram cloud 2FA password:", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + label = { Text("2FA Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { + if (passwordInput.isNotBlank()) TelegramRepository.submitPassword(passwordInput) + }), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + if (passwordInput.isNotBlank()) TelegramRepository.submitPassword(passwordInput) + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Submit Password") + } + } + } + } + + is TelegramAuthState.Ready -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Connected Account", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Logged in as ${state.firstName} (User ID: ${state.userId})", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Media Cache: ${formatBytes(cacheSize)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedButton( + onClick = { + TelegramRepository.clearCache() + cacheSize = TelegramRepository.getCacheSize() + } + ) { + Text("Clear Cache") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + TelegramRepository.disconnect() + cacheSize = 0L + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("Disconnect Account") + } + } + } + } + + is TelegramAuthState.WaitQr -> { + Text("Scan QR Code to Log In: ${state.link}") + } + + is TelegramAuthState.Error -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Authentication Error", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { TelegramRepository.startAuth() }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("Retry") + } + } + } + } + } + } +} + +private fun formatBytes(bytes: Long): String = when { + bytes <= 0 -> "0 KB" + bytes >= 1_000_000_000 -> "%.2f GB".format(bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0) + else -> "%.0f KB".format(bytes / 1_000.0) +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramClient.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramClient.android.kt new file mode 100644 index 000000000..d339531d6 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramClient.android.kt @@ -0,0 +1,180 @@ +package com.nuvio.app.features.telegram + +import android.content.Context +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import org.drinkless.tdlib.Client +import org.drinkless.tdlib.TdApi +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class TelegramApiException(message: String) : Exception(message) + +object TelegramClient { + private val log = Logger.withTag("TelegramClient") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _authState = MutableStateFlow(TelegramAuthState.Idle) + val authState: StateFlow = _authState.asStateFlow() + + private var client: Client? = null + private var appContext: Context? = null + + val isAvailable: Boolean by lazy { + try { + System.loadLibrary("tdjni") + true + } catch (e: Throwable) { + log.w { "TDLib native library not available: ${e.message}" } + false + } + } + + fun initialize(context: Context) { + appContext = context.applicationContext + if (client != null) return + scope.launch { + if (client != null) return@launch + if (!isAvailable) { + _authState.value = TelegramAuthState.Error("TDLib native library unavailable") + return@launch + } + _authState.value = TelegramAuthState.Initializing + try { + client = Client.create( + { update -> handleUpdate(update) }, + { e -> log.e(e) { "TDLib update exception" } }, + { e -> log.e(e) { "TDLib default exception" } } + ) + sendTdlibParameters() + } catch (e: Throwable) { + log.e(e) { "TDLib Client.create failed" } + _authState.value = TelegramAuthState.Error("TDLib initialization failed: ${e.message}") + } + } + } + + private fun sendTdlibParameters() { + val ctx = appContext ?: return + val dbDir = File(ctx.filesDir, "tdlib").absolutePath + val filesDir = File(ctx.filesDir, "tdlib_files").absolutePath + client?.send(TdApi.SetTdlibParameters().also { p -> + p.apiId = TelegramConfig.API_ID + p.apiHash = TelegramConfig.API_HASH + p.databaseDirectory = dbDir + p.filesDirectory = filesDir + p.useMessageDatabase = false + p.useSecretChats = false + p.systemLanguageCode = "en" + p.deviceModel = "Android" + p.applicationVersion = "1.0" + }, null) + } + + private fun handleUpdate(obj: TdApi.Object) { + when (obj) { + is TdApi.UpdateAuthorizationState -> handleAuthState(obj.authorizationState) + is TdApi.Error -> { + val state = _authState.value + if (state is TelegramAuthState.Initializing || + state is TelegramAuthState.WaitPhone || + state is TelegramAuthState.WaitQr || + state is TelegramAuthState.WaitCode || + state is TelegramAuthState.WaitPassword) { + _authState.value = TelegramAuthState.Error(obj.message) + } + } + } + } + + private fun handleAuthState(state: TdApi.AuthorizationState) { + log.d { "AuthorizationState -> ${state::class.simpleName}" } + when (state) { + is TdApi.AuthorizationStateWaitTdlibParameters -> sendTdlibParameters() + is TdApi.AuthorizationStateWaitPhoneNumber -> { + _authState.value = TelegramAuthState.WaitPhone + } + is TdApi.AuthorizationStateWaitCode -> { + val len = when (val t = state.codeInfo.type) { + is TdApi.AuthenticationCodeTypeTelegramMessage -> t.length + is TdApi.AuthenticationCodeTypeSms -> t.length + else -> 5 + } + _authState.value = TelegramAuthState.WaitCode(len) + } + is TdApi.AuthorizationStateWaitOtherDeviceConfirmation -> { + _authState.value = TelegramAuthState.WaitQr(state.link) + } + is TdApi.AuthorizationStateWaitPassword -> { + _authState.value = TelegramAuthState.WaitPassword + } + is TdApi.AuthorizationStateReady -> { + scope.launch { + val user = sendRequest(TdApi.GetMe()) as? TdApi.User + appContext?.let { ctx -> + File(ctx.filesDir, "tdlib_session_ok").createNewFile() + } + _authState.value = TelegramAuthState.Ready( + firstName = user?.firstName ?: "", + userId = user?.id ?: 0L + ) + } + } + is TdApi.AuthorizationStateClosing, + is TdApi.AuthorizationStateClosed -> { + _authState.value = TelegramAuthState.Idle + } + else -> {} + } + } + + fun requestQrCode() { + client?.send(TdApi.RequestQrCodeAuthentication(LongArray(0)), null) + } + + fun submitPhone(phone: String) { + client?.send(TdApi.SetAuthenticationPhoneNumber(phone, null), null) + } + + fun submitCode(code: String) { + client?.send(TdApi.CheckAuthenticationCode(code), null) + } + + fun submitPassword(password: String) { + client?.send(TdApi.CheckAuthenticationPassword(password), null) + } + + suspend fun sendRequest( + function: TdApi.Function, + timeoutMs: Long = 10_000L + ): TdApi.Object? = withTimeoutOrNull(timeoutMs) { + suspendCancellableCoroutine { cont -> + val c = client + if (c == null) { + cont.resume(null) + return@suspendCancellableCoroutine + } + c.send(function) { result -> + if (cont.isActive) { + if (result is TdApi.Error) cont.resumeWithException(TelegramApiException(result.message)) + else cont.resume(result) + } + } + } + } + + fun reset() { + client?.send(TdApi.Close(), null) + client = null + _authState.value = TelegramAuthState.Idle + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramRepository.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramRepository.android.kt new file mode 100644 index 000000000..f50ae3b17 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramRepository.android.kt @@ -0,0 +1,144 @@ +package com.nuvio.app.features.telegram + +import android.content.Context +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.StateFlow +import org.drinkless.tdlib.TdApi +import java.io.File + +data class TelegramVideoMessage( + val messageId: Long, + val chatId: Long, + val fileName: String, + val fileId: Int, + val fileSize: Long, + val duration: Int, + val mimeType: String, + val caption: String +) + +object TelegramRepository { + private val log = Logger.withTag("TelegramRepository") + private var appContext: Context? = null + + val authState: StateFlow get() = TelegramClient.authState + + fun initialize(context: Context) { + appContext = context.applicationContext + TelegramStreamingProxy.start() + val hasSession = sessionMarker(context).exists() + if (!hasSession) { + File(context.filesDir, "tdlib").deleteRecursively() + File(context.filesDir, "tdlib_files").deleteRecursively() + } else { + TelegramClient.initialize(context) + } + } + + private fun sessionMarker(context: Context) = File(context.filesDir, "tdlib_session_ok") + + fun isAuthenticated(): Boolean = TelegramClient.authState.value is TelegramAuthState.Ready + + fun startAuth() { + appContext?.let { TelegramClient.initialize(it) } + } + + fun requestQrCode() = TelegramClient.requestQrCode() + fun submitPhone(phone: String) = TelegramClient.submitPhone(phone) + fun submitCode(code: String) = TelegramClient.submitCode(code) + fun submitPassword(password: String) = TelegramClient.submitPassword(password) + + fun disconnect() { + TelegramClient.reset() + appContext?.let { wipeTdlibFiles(it) } + } + + private fun wipeTdlibFiles(context: Context) { + sessionMarker(context).delete() + File(context.filesDir, "tdlib").deleteRecursively() + File(context.filesDir, "tdlib_files").deleteRecursively() + log.d { "Wiped TDLib session and files" } + } + + fun getCacheSize(): Long { + val ctx = appContext ?: return 0L + val dir = File(ctx.filesDir, "tdlib_files") + return if (dir.exists()) dir.walkBottomUp().filter { it.isFile }.sumOf { it.length() } else 0L + } + + fun clearCache() { + val ctx = appContext ?: return + File(ctx.filesDir, "tdlib_files").listFiles()?.forEach { it.deleteRecursively() } + } + + suspend fun searchVideoMessages( + query: String, + limit: Int = 50 + ): List { + if (!isAuthenticated()) return emptyList() + + val filters = listOf( + TdApi.SearchMessagesFilterDocument(), + TdApi.SearchMessagesFilterVideo() + ) + val seen = mutableSetOf>() + val results = mutableListOf() + + for (filter in filters) { + val result = TelegramClient.sendRequest(TdApi.SearchMessages().also { req -> + req.chatList = null + req.query = query + req.offset = "" + req.limit = limit + req.filter = filter + }) + val found = (result as? TdApi.FoundMessages) ?: continue + + for (msg in found.messages) { + when (val content = msg.content) { + is TdApi.MessageDocument -> { + val mime = content.document.mimeType + if (!mime.startsWith("video/") && mime != "application/x-matroska") continue + val key = content.document.fileName to content.document.document.size + if (seen.add(key)) { + results.add( + TelegramVideoMessage( + messageId = msg.id, + chatId = msg.chatId, + fileName = content.document.fileName, + fileId = content.document.document.id, + fileSize = content.document.document.size, + duration = 0, + mimeType = mime, + caption = content.caption.text + ) + ) + } + } + is TdApi.MessageVideo -> { + val key = content.video.fileName to content.video.video.size + if (seen.add(key)) { + results.add( + TelegramVideoMessage( + messageId = msg.id, + chatId = msg.chatId, + fileName = content.video.fileName, + fileId = content.video.video.id, + fileSize = content.video.video.size, + duration = content.video.duration, + mimeType = content.video.mimeType, + caption = content.caption.text + ) + ) + } + } + else -> continue + } + } + } + + return results + } + + fun getStreamUrl(fileId: Int): String = TelegramStreamingProxy.getUrl(fileId) +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramSourceResolver.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramSourceResolver.android.kt new file mode 100644 index 000000000..a5380fc81 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramSourceResolver.android.kt @@ -0,0 +1,127 @@ +package com.nuvio.app.features.telegram + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull + +internal actual object TelegramSourceResolver { + private val log = Logger.withTag("TelegramResolver") + private const val SCORE_THRESHOLD = 55 + private const val SEARCH_TIMEOUT_MS = 20_000L + private const val MAX_RESULTS = 50 + + actual fun isEnabled(): Boolean = TelegramRepository.isAuthenticated() + + actual suspend fun resolve( + title: String, + year: Int?, + season: Int?, + episode: Int?, + imdbId: String, + isMovie: Boolean + ): List { + if (!isEnabled()) return emptyList() + + return try { + withTimeoutOrNull(SEARCH_TIMEOUT_MS) { + resolveInternal(title, year, season, episode, isMovie) + } ?: emptyList() + } catch (e: Exception) { + log.w(e) { "Telegram search error for '$title'" } + emptyList() + } + } + + private suspend fun resolveInternal( + title: String, + year: Int?, + season: Int?, + episode: Int?, + isMovie: Boolean + ): List { + val queries = if (season != null && episode != null) + TelegramSearchMatcher.buildSeriesQueries(title, season, episode) + else + TelegramSearchMatcher.buildMovieQueries(title, year) + + val seen = mutableSetOf>() + val allMessages = mutableListOf() + + coroutineScope { + queries.map { query -> + async { + try { + TelegramRepository.searchVideoMessages(query, MAX_RESULTS) + } catch (e: Exception) { + log.e(e) { "Telegram search failed for query '$query'" } + emptyList() + } + } + }.awaitAll().flatten().forEach { msg -> + if (seen.add(msg.fileName to msg.fileSize)) allMessages.add(msg) + } + } + + return allMessages + .mapNotNull { msg -> + val score = TelegramSearchMatcher.score( + fileName = msg.fileName, + caption = msg.caption, + title = title, + localizedTitle = null, + englishTitle = null, + year = year, + season = season, + episode = episode + ) + if (score < SCORE_THRESHOLD) null else msg + } + .map { msg -> + val streamUrl = TelegramRepository.getStreamUrl(msg.fileId) + val displayName = if (msg.fileName == "Default_Name.mkv" || msg.fileName == "Default_Name.mp4") + msg.caption.takeIf { it.isNotBlank() } ?: msg.fileName + else msg.fileName + val quality = parseQuality("${msg.fileName} ${msg.caption}") + + StreamItem( + name = "Telegram", + title = displayName, + description = displayName, + url = streamUrl, + addonName = "Telegram", + addonId = "telegram_native", + streamType = "telegram", + behaviorHints = com.nuvio.app.features.streams.StreamBehaviorHints( + videoSize = msg.fileSize, + filename = msg.fileName, + ) + ) + } + .sortedByDescending { it.behaviorHints.videoSize ?: 0L } + } + + private fun parseQuality(raw: String): String { + val t = raw.lowercase().replace(' ', '.') + fun has(vararg xs: String) = xs.any { it in t } + return when { + has("dvdscr", "screener", ".scr.") -> "SCR" + has(".cam.", "camrip", "hdcam", "hdts", "telesync") -> "CAM" + has("360", "36o") -> "360p" + has("480", "48o") -> "480p" + has("720", "72o") -> "720p" + has("1080", "1o8o", "108o", "1o80", ".fhd.") -> "1080p" + has("2160", "216o", ".4k.", ".uhd.", "ultrahd") -> "4K" + else -> "HD" + } + } + + private fun formatBytes(bytes: Long): String = when { + bytes <= 0 -> "" + bytes >= 1_000_000_000 -> "%.2f GB".format(bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0) + else -> "%.0f KB".format(bytes / 1_000.0) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramStreamingProxy.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramStreamingProxy.android.kt new file mode 100644 index 000000000..6878304f7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/telegram/TelegramStreamingProxy.android.kt @@ -0,0 +1,192 @@ +package com.nuvio.app.features.telegram + +import co.touchlab.kermit.Logger +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytesWriter +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import io.ktor.utils.io.writeFully +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.drinkless.tdlib.TdApi +import java.net.ServerSocket + +object TelegramStreamingProxy { + private val log = Logger.withTag("TelegramProxy") + private const val CHUNK_SIZE = 2 * 1024 * 1024 + private const val PREFETCH_SIZE = 20 * 1024 * 1024L + private const val DOWNLOAD_TIMEOUT_MS = 30_000L + private const val DOWNLOAD_PRIORITY = 32 + private const val POLL_INTERVAL_MS = 100L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var port: Int = 0 + private var server: io.ktor.server.engine.EmbeddedServer<*, *>? = null + @Volatile private var lastStreamedFileId: Int? = null + + + fun start() { + if (server != null) return + port = findFreePort() + server = embeddedServer(CIO, port = port) { + routing { + get("/file/{fileId}") { + val fileId = call.parameters["fileId"]?.toIntOrNull() + log.d { "Streaming request: fileId=$fileId range=${call.request.headers[HttpHeaders.Range]}" } + if (fileId == null) { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val prev = lastStreamedFileId + if (prev != null && prev != fileId) { + scope.launch { deleteFile(prev) } + } + lastStreamedFileId = fileId + + val rangeHeader = call.request.headers[HttpHeaders.Range] + val (rangeStart, rangeEnd) = parseRange(rangeHeader) + + val fileInfo = getFileInfo(fileId) + val totalSize = fileInfo?.second ?: 0L + val localPath = fileInfo?.first + log.d { "FileInfo: fileId=$fileId totalSize=$totalSize localPath=$localPath" } + + if (totalSize <= 0L) { + call.respond(HttpStatusCode.NotFound) + return@get + } + + val start = rangeStart ?: 0L + val end = rangeEnd ?: (totalSize - 1L) + val length = end - start + 1 + + call.response.header(HttpHeaders.ContentLength, length.toString()) + call.response.header(HttpHeaders.AcceptRanges, "bytes") + call.response.header( + HttpHeaders.ContentRange, + "bytes $start-$end/$totalSize" + ) + + val status = if (rangeHeader != null) HttpStatusCode.PartialContent else HttpStatusCode.OK + + call.respondBytesWriter( + contentType = io.ktor.http.ContentType.Video.Any, + status = status + ) { + var offset = start + while (offset <= end) { + val chunkSize = minOf(CHUNK_SIZE.toLong(), end - offset + 1).toInt() + val bytes = downloadChunk(fileId, localPath, offset, chunkSize) + if (bytes == null || bytes.isEmpty()) break + writeFully(bytes) + offset += bytes.size + } + } + } + } + } + server!!.start(wait = false) + log.d { "Streaming proxy started on port $port" } + } + + fun stop() { + lastStreamedFileId?.let { scope.launch { deleteFile(it) } } + lastStreamedFileId = null + server?.stop(0, 0) + server = null + log.d { "Streaming proxy stopped" } + } + + private suspend fun deleteFile(fileId: Int) { + runCatching { + TelegramClient.sendRequest(TdApi.CancelDownloadFile().also { req -> + req.fileId = fileId + req.onlyIfPending = false + }) + } + runCatching { + TelegramClient.sendRequest(TdApi.DeleteFile().also { it.fileId = fileId }) + log.d { "Deleted cached file $fileId" } + } + } + + fun getUrl(fileId: Int): String { + if (server == null) start() + val url = "http://127.0.0.1:$port/file/$fileId" + log.d { "Generated stream URL: $url" } + return url + } + + private suspend fun downloadChunk( + fileId: Int, + localPath: String?, + offset: Long, + limit: Int + ): ByteArray? { + withTimeoutOrNull(DOWNLOAD_TIMEOUT_MS) { + TelegramClient.sendRequest(TdApi.DownloadFile().also { req -> + req.fileId = fileId + req.priority = DOWNLOAD_PRIORITY + req.offset = offset + req.limit = PREFETCH_SIZE + req.synchronous = false + }) + } + + val ready = withTimeoutOrNull(DOWNLOAD_TIMEOUT_MS) { + var attempts = 0 + while (attempts < 300) { + val file = TelegramClient.sendRequest(TdApi.GetFile(fileId)) as? TdApi.File + val local = file?.local + if (local != null && (local.isDownloadingCompleted || local.downloadedPrefixSize >= limit)) { + return@withTimeoutOrNull true + } + delay(POLL_INTERVAL_MS) + attempts++ + } + false + } + if (ready != true) return null + + val data = TelegramClient.sendRequest( + TdApi.ReadFilePart(fileId, offset, limit.toLong()) + ) as? TdApi.Data + return data?.data?.takeIf { it.isNotEmpty() } + } + + private suspend fun getFileInfo(fileId: Int): Pair? { + val file = TelegramClient.sendRequest(TdApi.GetFile(fileId)) as? TdApi.File ?: return null + val totalSize = file.size.takeIf { it > 0 } ?: file.expectedSize + val localPath = file.local?.path?.takeIf { it.isNotBlank() } + return Pair(localPath, totalSize) + } + + private fun parseRange(header: String?): Pair { + if (header == null) return Pair(null, null) + return try { + val range = header.removePrefix("bytes=") + val parts = range.split("-") + val start = parts.getOrNull(0)?.toLongOrNull() + val end = parts.getOrNull(1)?.toLongOrNull() + Pair(start, end) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Pair(null, null) + } + } + + private fun findFreePort(): Int { + ServerSocket(0).use { return it.localPort } + } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index affcc835c..e760f06db 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -438,7 +438,10 @@ STREAMS Supporters & Contributors TMDB Enrichment + Telegram Integration + Search and stream movies & shows directly from your Telegram chats Trakt + ABOUT Account and sync status ACCOUNT diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt index 2925674f2..fec357c5e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt @@ -9,13 +9,17 @@ import nuvio.composeapp.generated.resources.settings_integrations_mdblist_descri import nuvio.composeapp.generated.resources.settings_integrations_debrid_description import nuvio.composeapp.generated.resources.settings_integrations_section_title import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description +import nuvio.composeapp.generated.resources.compose_settings_page_telegram +import nuvio.composeapp.generated.resources.settings_integrations_telegram_description import org.jetbrains.compose.resources.stringResource + internal fun LazyListScope.integrationsContent( isTablet: Boolean, onTmdbClick: () -> Unit, onMdbListClick: () -> Unit, onDebridClick: () -> Unit, + onTelegramClick: () -> Unit, ) { item { SettingsSection( @@ -45,7 +49,15 @@ internal fun LazyListScope.integrationsContent( isTablet = isTablet, onClick = onDebridClick, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_telegram), + description = stringResource(Res.string.settings_integrations_telegram_description), + isTablet = isTablet, + onClick = onTelegramClick, + ) } } } } + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 14bc6114f..735ebc116 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -30,7 +30,9 @@ import nuvio.composeapp.generated.resources.compose_settings_page_root import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment +import nuvio.composeapp.generated.resources.compose_settings_page_telegram import nuvio.composeapp.generated.resources.compose_settings_page_trakt + import nuvio.composeapp.generated.resources.settings_account import org.jetbrains.compose.resources.StringResource @@ -149,6 +151,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Integrations, ), + Telegram( + titleRes = Res.string.compose_settings_page_telegram, + category = SettingsCategory.General, + parentPage = Integrations, + ), TraktAuthentication( titleRes = Res.string.compose_settings_page_trakt, category = SettingsCategory.Account, @@ -156,6 +163,7 @@ internal enum class SettingsPage( ), } + internal val SettingsPage.opensInlineOnTablet: Boolean get() = parentPage != null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 35e003b35..d024a4343 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -776,6 +776,7 @@ private fun MobileSettingsScreen( onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, onDebridClick = { onPageChange(SettingsPage.Debrid) }, + onTelegramClick = { onPageChange(SettingsPage.Telegram) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = false, @@ -789,6 +790,13 @@ private fun MobileSettingsScreen( isTablet = false, settings = debridSettings, ) + SettingsPage.Telegram -> item { + TelegramSettingsPage( + isTablet = false, + onBack = { onPageChange(SettingsPage.Integrations) }, + ) + } + SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = false, uiState = traktAuthUiState, @@ -1188,6 +1196,7 @@ private fun TabletSettingsScreen( onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, onDebridClick = { onPageChange(SettingsPage.Debrid) }, + onTelegramClick = { onPageChange(SettingsPage.Telegram) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = true, @@ -1201,6 +1210,13 @@ private fun TabletSettingsScreen( isTablet = true, settings = debridSettings, ) + SettingsPage.Telegram -> item { + TelegramSettingsPage( + isTablet = true, + onBack = { onPageChange(SettingsPage.Integrations) }, + ) + } + SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = true, uiState = traktAuthUiState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TelegramSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TelegramSettingsPage.kt new file mode 100644 index 000000000..2532b4240 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TelegramSettingsPage.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal expect fun TelegramSettingsPage( + isTablet: Boolean, + onBack: () -> Unit, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 60ac5b364..65b324e26 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -15,7 +15,9 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId import com.nuvio.app.features.plugins.PluginsUiState +import com.nuvio.app.features.telegram.TelegramSourceResolver import kotlinx.coroutines.CoroutineScope + import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -47,7 +49,7 @@ object StreamsRepository { ): String = "$type::$videoId::$season::$episode::$manualSelection" - fun load(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { + fun load(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false, mediaTitle: String? = null, year: Int? = null) { load( type = type, videoId = videoId, @@ -56,10 +58,12 @@ object StreamsRepository { episode = episode, manualSelection = manualSelection, forceRefresh = false, + mediaTitle = mediaTitle, + year = year, ) } - fun reload(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { + fun reload(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false, mediaTitle: String? = null, year: Int? = null) { load( type = type, videoId = videoId, @@ -68,10 +72,12 @@ object StreamsRepository { episode = episode, manualSelection = manualSelection, forceRefresh = true, + mediaTitle = mediaTitle, + year = year, ) } - private fun load(type: String, videoId: String, parentMetaId: String?, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) { + private fun load(type: String, videoId: String, parentMetaId: String?, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean, mediaTitle: String? = null, year: Int? = null) { val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.uiState.value @@ -165,7 +171,8 @@ object StreamsRepository { groupByRepository = pluginUiState.groupStreamsByRepository, ) - if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + val telegramEnabled = TelegramSourceResolver.isEnabled() + if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && !telegramEnabled) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -194,7 +201,7 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && !telegramEnabled) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -205,6 +212,17 @@ object StreamsRepository { // Initialise loading placeholders val installedAddonOrder = streamAddons.map { it.addonName } + val telegramGroup = if (TelegramSourceResolver.isEnabled()) { + listOf( + AddonStreamGroup( + addonName = "Telegram", + addonId = "telegram_native", + streams = emptyList(), + isLoading = true, + ) + ) + } else emptyList() + val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> AddonStreamGroup( addonName = addon.addonName, @@ -219,7 +237,7 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) - }, installedAddonOrder) + } + telegramGroup, installedAddonOrder) val isInitiallyLoading = initialGroups.any { it.isLoading } _uiState.value = StreamsUiState( requestToken = requestToken, @@ -238,7 +256,9 @@ object StreamsRepository { .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() val totalTasks = streamAddons.size + - pluginProviderGroups.sumOf { it.scrapers.size } + pluginProviderGroups.sumOf { it.scrapers.size } + + (if (TelegramSourceResolver.isEnabled()) 1 else 0) + val installedAddonNames = installedAddonOrder.toSet() val installedAddonIds = streamAddons.map { it.addonId }.toSet() @@ -514,7 +534,30 @@ object StreamsRepository { } } + if (TelegramSourceResolver.isEnabled()) { + + launch { + val searchTitle = mediaTitle?.takeIf { it.isNotBlank() } ?: parentMetaId ?: videoId + val streams = TelegramSourceResolver.resolve( + title = searchTitle, + year = year, + season = season, + episode = episode, + imdbId = videoId, + isMovie = type == "movie", + ) + val group = AddonStreamGroup( + addonName = "Telegram", + addonId = "telegram_native", + streams = streams, + isLoading = false, + ) + publishCompletion(StreamLoadCompletion.Addon(group)) + } + } + repeat(totalTasks) { + when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { val result = completion.group diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index fd9f2615c..9c373d94d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -187,7 +187,7 @@ fun StreamsScreen( } } - LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) { + LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection, title) { StreamsRepository.load( type = type, videoId = videoId, @@ -195,6 +195,7 @@ fun StreamsScreen( season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, + mediaTitle = title, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramAuthState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramAuthState.kt new file mode 100644 index 000000000..c9cdc4560 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramAuthState.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.telegram + +sealed class TelegramAuthState { + data object Idle : TelegramAuthState() + data object Initializing : TelegramAuthState() + data object WaitPhone : TelegramAuthState() + data class WaitQr(val link: String) : TelegramAuthState() + data class WaitCode(val codeLength: Int = 5) : TelegramAuthState() + data object WaitPassword : TelegramAuthState() + data class Ready(val firstName: String, val userId: Long) : TelegramAuthState() + data class Error(val message: String) : TelegramAuthState() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramConfig.kt new file mode 100644 index 000000000..9b086f2aa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramConfig.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.telegram + +object TelegramConfig { + var API_ID: Int = 23905496 + var API_HASH: String = "1e48b355edfe55f9a4fbf8d3c2324628" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramSearchMatcher.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramSearchMatcher.kt new file mode 100644 index 000000000..b4bc16291 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramSearchMatcher.kt @@ -0,0 +1,166 @@ +package com.nuvio.app.features.telegram + +object TelegramSearchMatcher { + + private const val SEP = """[\s._\-x+,&:]{0,2}""" + private const val SEP_MID = """[\s._\-x+,&:]{0,4}""" + private val EPISODE_PATTERN = Regex( + """[Ss][e]?(?:ason)?$SEP(\d{1,2})${SEP_MID}[Ee][p]?(?:isode)?$SEP(\d{1,4})""" + + """|ע(?:ונה)?$SEP(\d{1,2})${SEP_MID}פ(?:רק)?$SEP(\d{1,4})""", + RegexOption.IGNORE_CASE + ) + private val EPISODE_ONLY_PATTERN = Regex("""פ(?:רק)?[\s._\-x+,&:]{0,2}(\d{1,4})""") + private val YEAR_PATTERN = Regex("""\b(19|20)\d{2}\b""") + private val NOISE = Regex("""[._\-\[\]()'",!?:]""") + private val MULTI_SPACE = Regex("""\s+""") + private val SIZE_SUFFIX = Regex("""\.(mkv|mp4|avi|mov|wmv|m4v|ts|m2ts)$""", RegexOption.IGNORE_CASE) + private val HEBREW_RANGE = 0x0590..0x05FF + + fun score( + fileName: String, + caption: String, + title: String, + localizedTitle: String? = null, + englishTitle: String? = null, + year: Int?, + season: Int?, + episode: Int? + ): Int { + val combined = "$fileName $caption" + val normalizedCombined = normalize(combined) + val normalizedTitle = normalize(title) + val normalizedLocalized = localizedTitle?.let { normalize(it) } + val normalizedEnglish = englishTitle?.let { normalize(it) } + + val engMatch = normalizedEnglish != null && normalizedEnglish.isNotBlank() && normalizedCombined.contains(normalizedEnglish) + val locMatch = normalizedLocalized != null && normalizedLocalized.isNotBlank() && normalizedCombined.contains(normalizedLocalized) + val appMatch = normalizedCombined.contains(normalizedTitle) + + if (!engMatch && !locMatch && !appMatch) return 0 + + var score = 60 + + if (year != null) { + val fileYears = YEAR_PATTERN.findAll(combined).map { it.value.toInt() }.toList() + score += when { + fileYears.contains(year) -> 20 + fileYears.any { kotlin.math.abs(it - year) == 1 } -> 5 + fileYears.isEmpty() -> 5 + else -> -10 + } + } + + if (season != null && episode != null) { + val seFile = extractSeasonEpisode(fileName) + val seCaption = extractSeasonEpisode(caption) + val rightSE = (seFile?.first == season && seFile.second == episode) || + (seCaption?.first == season && seCaption.second == episode) + when { + rightSE -> score += 20 + seFile != null || seCaption != null -> return 0 + season == 1 -> { + val epFile = extractEpisodeOnly(fileName) + val epCaption = extractEpisodeOnly(caption) + when { + epFile == episode || epCaption == episode -> score += 20 + epFile != null || epCaption != null -> return 0 + else -> score -= 10 + } + } + else -> score -= 10 + } + } else if (season == null) { + if (EPISODE_PATTERN.containsMatchIn(combined) || EPISODE_PATTERN.containsMatchIn(normalizedCombined)) { + score -= 20 + } + } + + return score.coerceIn(0, 100) + } + + private fun extractSeasonEpisode(text: String): Pair? { + val m = EPISODE_PATTERN.find(text) ?: EPISODE_PATTERN.find(normalize(text)) ?: return null + val s = m.groupValues[1].toIntOrNull() ?: m.groupValues[3].toIntOrNull() ?: return null + val e = m.groupValues[2].toIntOrNull() ?: m.groupValues[4].toIntOrNull() ?: return null + return s to e + } + + private fun extractEpisodeOnly(text: String): Int? { + val m = EPISODE_ONLY_PATTERN.find(text) ?: EPISODE_ONLY_PATTERN.find(normalize(text)) ?: return null + return m.groupValues[1].toIntOrNull() + } + + fun buildMovieQueries(title: String, year: Int?, localizedTitle: String? = null, englishTitle: String? = null): List { + val primary = englishTitle?.let { cleanTitle(it) } ?: cleanTitle(title) + val localized = localizedTitle?.let { cleanTitle(it) } + val queries = mutableListOf() + if (year != null) queries.add("$primary $year") + queries.add(primary) + if (localized != null && !localized.equals(primary, ignoreCase = true)) { + if (year != null) queries.add("$localized $year") + queries.add(localized) + } + return queries.distinct() + } + + fun buildSeriesQueries( + title: String, + season: Int, + episode: Int, + localizedTitle: String? = null, + englishTitle: String? = null, + languageCode: String = "en" + ): List { + val engBase = englishTitle?.let { cleanTitle(it) } ?: cleanTitle(title) + val locBase = localizedTitle?.let { cleanTitle(it) } + val titlesAreSame = locBase == null || locBase.equals(engBase, ignoreCase = true) + val s = season.toString() + val e = episode.toString() + val s2 = season.toString().padStart(2, '0') + val e2 = episode.toString().padStart(2, '0') + + val queries = mutableListOf() + + if (languageCode == "he") { + val hebTitle = if (titlesAreSame) engBase else locBase ?: engBase + queries += listOf( + "$hebTitle ע$s פ$e", + "$hebTitle ע${s}פ${e}", + "$hebTitle עונה $s פרק $e", + ) + if (season == 1) queries += listOf("$hebTitle פ$e", "$hebTitle פרק $e") + } + + if (!titlesAreSame && locBase != null) { + queries += listOf( + "$locBase s${s}e${e}", + "$locBase s${s2}e${e2}", + "$locBase s$s e$e", + "$locBase s$s2 e$e2", + ) + } + + queries += listOf( + "$engBase s${s}e${e}", + "$engBase s${s2}e${e2}", + "$engBase s$s e$e", + "$engBase s$s2 e$e2", + ) + + return queries.map { it.lowercase() }.distinct() + } + + fun isHebrew(s: String) = s.any { it.code in HEBREW_RANGE } + + private fun cleanTitle(title: String): String { + val stripped = title.replace(":", "").replace(" ", " ").trim() + return stripped + } + + private fun normalize(text: String): String = + text.replace(SIZE_SUFFIX, "") + .replace(NOISE, " ") + .replace(MULTI_SPACE, " ") + .trim() + .lowercase() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramSourceResolver.kt new file mode 100644 index 000000000..9a8425ae3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/telegram/TelegramSourceResolver.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.telegram + +import com.nuvio.app.features.streams.StreamItem + +internal expect object TelegramSourceResolver { + fun isEnabled(): Boolean + suspend fun resolve( + title: String, + year: Int?, + season: Int? = null, + episode: Int? = null, + imdbId: String = "", + isMovie: Boolean = true + ): List +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c931ad83..a275c1f80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] -agp = "9.2.0" +agp = "9.1.0" + android-compileSdk = "37" android-compileSdkMinor = "0" android-minSdk = "24" @@ -89,6 +90,9 @@ ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentry" } +ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } +ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }