This commit is contained in:
akzzy 2026-07-26 20:00:22 +00:00 committed by GitHub
commit 91ade453a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1281 additions and 10 deletions

View file

@ -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

Binary file not shown.

View file

@ -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.**

Binary file not shown.

Binary file not shown.

View file

@ -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)

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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 }
}
}

View file

@ -438,7 +438,10 @@
<string name="settings_appearance_section_streams">STREAMS</string>
<string name="compose_settings_page_supporters_contributors">Supporters &amp; 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 &amp; 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>

View file

@ -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,
)
}
}
}
}

View file

@ -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

View file

@ -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,

View file

@ -0,0 +1,9 @@
package com.nuvio.app.features.settings
import androidx.compose.runtime.Composable
@Composable
internal expect fun TelegramSettingsPage(
isTablet: Boolean,
onBack: () -> Unit,
)

View file

@ -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

View file

@ -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,
)
}

View file

@ -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()
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.telegram
object TelegramConfig {
var API_ID: Int = 23905496
var API_HASH: String = "1e48b355edfe55f9a4fbf8d3c2324628"
}

View file

@ -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()
}

View file

@ -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>
}

View file

@ -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" }