mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Merge f5d401edf6 into 00cc012193
This commit is contained in:
commit
91ade453a8
25 changed files with 1281 additions and 10 deletions
|
|
@ -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)
|
||||
|
|
|
|||
BIN
composeApp/libs/tdlib.jar
Normal file
BIN
composeApp/libs/tdlib.jar
Normal file
Binary file not shown.
6
composeApp/proguard-rules.pro
vendored
6
composeApp/proguard-rules.pro
vendored
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
BIN
composeApp/src/androidMain/jniLibs/arm64-v8a/libtdjni.so
Normal file
BIN
composeApp/src/androidMain/jniLibs/arm64-v8a/libtdjni.so
Normal file
Binary file not shown.
BIN
composeApp/src/androidMain/jniLibs/armeabi-v7a/libtdjni.so
Normal file
BIN
composeApp/src/androidMain/jniLibs/armeabi-v7a/libtdjni.so
Normal file
Binary file not shown.
BIN
composeApp/src/androidMain/jniLibs/x86/libtdjni.so
Normal file
BIN
composeApp/src/androidMain/jniLibs/x86/libtdjni.so
Normal file
Binary file not shown.
BIN
composeApp/src/androidMain/jniLibs/x86_64/libtdjni.so
Normal file
BIN
composeApp/src/androidMain/jniLibs/x86_64/libtdjni.so
Normal file
Binary file not shown.
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>(TelegramAuthState.Idle)
|
||||
val authState: StateFlow<TelegramAuthState> = _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<out TdApi.Object>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TelegramAuthState> 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<TelegramVideoMessage> {
|
||||
if (!isAuthenticated()) return emptyList()
|
||||
|
||||
val filters = listOf(
|
||||
TdApi.SearchMessagesFilterDocument(),
|
||||
TdApi.SearchMessagesFilterVideo()
|
||||
)
|
||||
val seen = mutableSetOf<Pair<String, Long>>()
|
||||
val results = mutableListOf<TelegramVideoMessage>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<StreamItem> {
|
||||
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<StreamItem> {
|
||||
val queries = if (season != null && episode != null)
|
||||
TelegramSearchMatcher.buildSeriesQueries(title, season, episode)
|
||||
else
|
||||
TelegramSearchMatcher.buildMovieQueries(title, year)
|
||||
|
||||
val seen = mutableSetOf<Pair<String, Long>>()
|
||||
val allMessages = mutableListOf<TelegramVideoMessage>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String?, Long>? {
|
||||
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<Long?, Long?> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -438,7 +438,10 @@
|
|||
<string name="settings_appearance_section_streams">STREAMS</string>
|
||||
<string name="compose_settings_page_supporters_contributors">Supporters & Contributors</string>
|
||||
<string name="compose_settings_page_tmdb_enrichment">TMDB Enrichment</string>
|
||||
<string name="compose_settings_page_telegram">Telegram Integration</string>
|
||||
<string name="settings_integrations_telegram_description">Search and stream movies & shows directly from your Telegram chats</string>
|
||||
<string name="compose_settings_page_trakt">Trakt</string>
|
||||
|
||||
<string name="compose_settings_root_about_section">ABOUT</string>
|
||||
<string name="compose_settings_root_account_description">Account and sync status</string>
|
||||
<string name="compose_settings_root_account_section">ACCOUNT</string>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
internal expect fun TelegramSettingsPage(
|
||||
isTablet: Boolean,
|
||||
onBack: () -> Unit,
|
||||
)
|
||||
|
|
@ -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<String, String>()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.telegram
|
||||
|
||||
object TelegramConfig {
|
||||
var API_ID: Int = 23905496
|
||||
var API_HASH: String = "1e48b355edfe55f9a4fbf8d3c2324628"
|
||||
}
|
||||
|
|
@ -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<Int, Int>? {
|
||||
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<String> {
|
||||
val primary = englishTitle?.let { cleanTitle(it) } ?: cleanTitle(title)
|
||||
val localized = localizedTitle?.let { cleanTitle(it) }
|
||||
val queries = mutableListOf<String>()
|
||||
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<String> {
|
||||
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<String>()
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -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<StreamItem>
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue