Merge branch 'debridstreaming' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-16 03:46:27 +05:30
commit 87b8a3691f
47 changed files with 3973 additions and 72 deletions

View file

@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
)
}
outDir.resolve("com/nuvio/app/features/debrid").apply {
mkdirs()
resolve("DebridConfig.kt").writeText(
"""
|package com.nuvio.app.features.debrid
|
|object DebridConfig {
| const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}"
|}
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/core/build").apply {
mkdirs()
resolve("AppVersionConfig.kt").writeText(

View file

@ -15,6 +15,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.collection.CollectionStorage
import com.nuvio.app.features.debrid.DebridSettingsStorage
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
import com.nuvio.app.features.downloads.DownloadsStorage
@ -73,6 +74,7 @@ class MainActivity : AppCompatActivity() {
SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext)
DebridSettingsStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext)
TraktAuthStorage.initialize(applicationContext)

View file

@ -0,0 +1,140 @@
package com.nuvio.app.features.debrid
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncInt
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncInt
import com.nuvio.app.core.sync.encodeSyncString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
actual object DebridSettingsStorage {
private const val preferencesName = "nuvio_debrid_settings"
private const val enabledKey = "debrid_enabled"
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
private const val streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey)
actual fun saveTorboxApiKey(apiKey: String) {
saveString(torboxApiKeyKey, apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey)
actual fun saveRealDebridApiKey(apiKey: String) {
saveString(realDebridApiKeyKey, apiKey)
}
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
saveInt(instantPlaybackPreparationLimitKey, limit)
}
actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey)
actual fun saveStreamNameTemplate(template: String) {
saveString(streamNameTemplateKey, template)
}
actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey)
actual fun saveStreamDescriptionTemplate(template: String) {
saveString(streamDescriptionTemplateKey, template)
}
private fun loadBoolean(key: String): Boolean? =
preferences?.let { sharedPreferences ->
val scopedKey = ProfileScopedKey.of(key)
if (sharedPreferences.contains(scopedKey)) {
sharedPreferences.getBoolean(scopedKey, false)
} else {
null
}
}
private fun saveBoolean(key: String, enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(key), enabled)
?.apply()
}
private fun loadInt(key: String): Int? =
preferences?.let { sharedPreferences ->
val scopedKey = ProfileScopedKey.of(key)
if (sharedPreferences.contains(scopedKey)) {
sharedPreferences.getInt(scopedKey, 0)
} else {
null
}
}
private fun saveInt(key: String, value: Int) {
preferences
?.edit()
?.putInt(ProfileScopedKey.of(key), value)
?.apply()
}
private fun loadString(key: String): String? =
preferences?.getString(ProfileScopedKey.of(key), null)
private fun saveString(key: String, value: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(key), value)
?.apply()
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) }
loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
preferences?.edit()?.apply {
syncKeys.forEach { remove(ProfileScopedKey.of(it)) }
}?.apply()
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
}

View file

@ -1068,7 +1068,7 @@
<string name="streams_resume_from_percent">Pokračovat od %1$d%%</string>
<string name="streams_resume_from_time">Pokračovat od %1$s</string>
<string name="streams_size">VELIKOST %1$s</string>
<string name="streams_torrent_not_supported">Torrent streamy nejsou podporovány</string>
<string name="streams_torrent_not_supported">Tento typ streamu není podporován</string>
<string name="trailer_close">Zavřít trailer</string>
<string name="trailer_unable_to_play">Trailer nelze přehrát</string>
<string name="trakt_lists_load_failed">Nepodařilo se načíst seznamy Trakt</string>

View file

@ -1100,7 +1100,7 @@
<string name="streams_resume_from_percent">Lanjutkan dari %1$d%</string>
<string name="streams_resume_from_time">Lanjutkan dari %1$s</string>
<string name="streams_size">UKURAN %1$s</string>
<string name="streams_torrent_not_supported">Stream torrent tidak didukung</string>
<string name="streams_torrent_not_supported">Jenis stream ini tidak didukung</string>
<string name="trailer_close">Tutup trailer</string>
<string name="trailer_unable_to_play">Tidak dapat memutar trailer</string>
<string name="trakt_lists_load_failed">Gagal memuat daftar Trakt</string>

View file

@ -18,6 +18,7 @@
<string name="action_resume">Resume</string>
<string name="action_retry">Retry</string>
<string name="action_save">Save</string>
<string name="action_validate">Validate</string>
<string name="addon_installing">Installing</string>
<string name="addon_title">Addons</string>
<string name="addons_badge_active">Active</string>
@ -365,6 +366,7 @@
<string name="compose_settings_page_appearance">Layout</string>
<string name="compose_settings_page_content_discovery">Content &amp; Discovery</string>
<string name="compose_settings_page_continue_watching">Continue Watching</string>
<string name="compose_settings_page_debrid">Debrid</string>
<string name="compose_settings_page_homescreen">Home Layout</string>
<string name="compose_settings_page_integrations">Integrations</string>
<string name="compose_settings_page_licenses_attributions">Licenses &amp; Attribution</string>
@ -573,6 +575,26 @@
<string name="settings_integrations_section_title">Integrations</string>
<string name="settings_integrations_tmdb_description">Metadata enrichment controls</string>
<string name="settings_integrations_mdblist_description">External ratings providers</string>
<string name="settings_integrations_debrid_description">Cloud account sources</string>
<string name="settings_debrid_section_title">Debrid</string>
<string name="settings_debrid_enable">Enable sources</string>
<string name="settings_debrid_enable_description">Show playable results from connected accounts.</string>
<string name="settings_debrid_add_key_first">Add an API key first.</string>
<string name="settings_debrid_section_providers">Account</string>
<string name="settings_debrid_provider_torbox_description">Connect your Torbox account.</string>
<string name="settings_debrid_section_instant_playback">Instant Playback</string>
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
<string name="settings_debrid_prepare_instant_playback_description">Resolve the first sources before playback starts.</string>
<string name="settings_debrid_prepare_stream_count">Sources to prepare</string>
<string name="settings_debrid_prepare_count_one">1 source</string>
<string name="settings_debrid_prepare_count_many">%1$d sources</string>
<string name="settings_debrid_section_formatting">Formatting</string>
<string name="settings_debrid_name_template">Name template</string>
<string name="settings_debrid_name_template_description">Controls how source names appear.</string>
<string name="settings_debrid_description_template">Description template</string>
<string name="settings_debrid_description_template_description">Controls the metadata shown under each source.</string>
<string name="settings_debrid_key_valid">API key validated.</string>
<string name="settings_debrid_key_invalid">Could not validate this API key.</string>
<string name="settings_mdb_add_api_key_first">Add your MDBList API key below before turning ratings on.</string>
<string name="settings_mdb_api_key_description">Required to fetch ratings from MDBList</string>
<string name="settings_mdb_api_key_label">API Key</string>
@ -1107,7 +1129,10 @@
<string name="streams_resume_from_percent">Resume from %1$d%</string>
<string name="streams_resume_from_time">Resume from %1$s</string>
<string name="streams_size">SIZE %1$s</string>
<string name="streams_torrent_not_supported">Torrent streams are not supported</string>
<string name="streams_torrent_not_supported">This stream type is not supported</string>
<string name="debrid_missing_api_key">Add a Debrid API key in Settings.</string>
<string name="debrid_stream_stale">This Debrid result expired. Refreshing streams.</string>
<string name="debrid_resolve_failed">Could not resolve this Debrid stream.</string>
<string name="external_player_failed">Couldn&apos;t open external player</string>
<string name="external_player_not_configured">Choose an external player in settings first</string>
<string name="external_player_unavailable">No external player is available</string>

View file

@ -106,6 +106,9 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.downloads.DownloadsScreen
import com.nuvio.app.features.details.MetaDetailsRepository
@ -159,6 +162,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
import com.nuvio.app.features.collection.FolderDetailScreen
import com.nuvio.app.features.collection.FolderDetailRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLaunch
import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamLinkCacheRepository
@ -1316,6 +1320,8 @@ private fun MainAppContent(
return@composable
}
val pauseDescription = launch.pauseDescription
val streamRouteScope = rememberCoroutineScope()
var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) }
val lifecycleOwner = backStackEntry
DisposableEffect(lifecycleOwner, route.launchId) {
val observer = LifecycleEventObserver { _, event ->
@ -1465,7 +1471,30 @@ private fun MainAppContent(
if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val stream = when (
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = selectedStream,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
) {
is DirectDebridPlayableResult.Success -> resolved.stream
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
StreamsRepository.consumeAutoPlay()
if (resolved == DirectDebridPlayableResult.Stale) {
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
}
return@LaunchedEffect
}
}
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
@ -1537,12 +1566,46 @@ private fun MainAppContent(
}
fun openSelectedStream(
stream: com.nuvio.app.features.streams.StreamItem,
stream: StreamItem,
resolvedResumePositionMs: Long?,
resolvedResumeProgressFraction: Float?,
forceExternal: Boolean,
forceInternal: Boolean,
) {
if (stream.isDirectDebridStream && stream.directPlaybackUrl == null) {
if (resolvingDebridStream) return
streamRouteScope.launch {
resolvingDebridStream = true
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = stream,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
resolvingDebridStream = false
when (resolved) {
is DirectDebridPlayableResult.Success -> openSelectedStream(
stream = resolved.stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = forceExternal,
forceInternal = forceInternal,
)
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
if (resolved == DirectDebridPlayableResult.Stale) {
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
}
}
}
}
return
}
val sourceUrl = stream.directPlaybackUrl ?: return
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
@ -1604,47 +1667,69 @@ private fun MainAppContent(
)
}
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
)
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
},
onBack = {
StreamsRepository.clear()
navController.popBackStack()
},
modifier = Modifier.fillMaxSize(),
)
Box(modifier = Modifier.fillMaxSize()) {
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
)
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
},
onBack = {
StreamsRepository.clear()
navController.popBackStack()
},
modifier = Modifier.fillMaxSize(),
)
if (resolvingDebridStream) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator(color = Color.White)
Text(
text = stringResource(Res.string.streams_finding_source),
color = Color.White.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
composable<PlayerRoute>(
enterTransition = {

View file

@ -6,6 +6,8 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.debrid.DebridSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.mdblist.MdbListMetadataService
@ -157,6 +159,7 @@ object ProfileSettingsSync {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" },
DebridSettingsRepository.uiState.map { "debrid" },
TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
@ -202,6 +205,7 @@ object ProfileSettingsSync {
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
@ -226,6 +230,9 @@ object ProfileSettingsSync {
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
PlayerSettingsRepository.onProfileChanged()
DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings)
DebridSettingsRepository.onProfileChanged()
TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings)
TmdbSettingsRepository.onProfileChanged()
@ -255,6 +262,7 @@ object ProfileSettingsSync {
ThemeSettingsRepository.ensureLoaded()
PosterCardStyleRepository.ensureLoaded()
PlayerSettingsRepository.ensureLoaded()
DebridSettingsRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
@ -277,6 +285,7 @@ object ProfileSettingsSync {
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}",
"debrid=${DebridSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
@ -299,6 +308,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
@SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",

View file

@ -0,0 +1,244 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.addons.RawHttpResponse
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
internal data class DebridApiResponse<T>(
val status: Int,
val body: T?,
val rawBody: String,
) {
val isSuccessful: Boolean
get() = status in 200..299
}
internal object DebridApiJson {
@OptIn(ExperimentalSerializationApi::class)
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
}
internal object TorboxApiClient {
private const val BASE_URL = "https://api.torbox.app"
suspend fun validateApiKey(apiKey: String): Boolean =
getUser(apiKey.trim()).status in 200..299
private suspend fun getUser(apiKey: String): RawHttpResponse =
httpRequestRaw(
method = "GET",
url = "$BASE_URL/v1/api/user/me",
headers = authHeaders(apiKey),
body = "",
)
suspend fun createTorrent(apiKey: String, magnet: String): DebridApiResponse<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>> {
val boundary = "NuvioDebrid${magnet.hashCode().toUInt()}"
val body = multipartFormBody(
boundary = boundary,
"magnet" to magnet,
"add_only_if_cached" to "true",
"allow_zip" to "false",
)
return request(
method = "POST",
url = "$BASE_URL/v1/api/torrents/createtorrent",
apiKey = apiKey,
body = body,
contentType = "multipart/form-data; boundary=$boundary",
)
}
suspend fun getTorrent(apiKey: String, id: Int): DebridApiResponse<TorboxEnvelopeDto<TorboxTorrentDataDto>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/torrents/mylist?${
queryString(
"id" to id.toString(),
"bypass_cache" to "true",
)
}",
apiKey = apiKey,
)
suspend fun requestDownloadLink(
apiKey: String,
torrentId: Int,
fileId: Int?,
): DebridApiResponse<TorboxEnvelopeDto<String>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/torrents/requestdl?${
queryString(
"token" to apiKey,
"torrent_id" to torrentId.toString(),
"file_id" to fileId?.toString(),
"zip_link" to "false",
"redirect" to "false",
"append_name" to "false",
)
}",
apiKey = apiKey,
)
private suspend inline fun <reified T> request(
method: String,
url: String,
apiKey: String,
body: String = "",
contentType: String? = null,
): DebridApiResponse<T> {
val headers = authHeaders(apiKey) + listOfNotNull(
contentType?.let { "Content-Type" to it },
"Accept" to "application/json",
)
val response = httpRequestRaw(
method = method,
url = url,
headers = headers,
body = body,
)
return DebridApiResponse(
status = response.status,
body = response.decodeBody<T>(),
rawBody = response.body,
)
}
private fun authHeaders(apiKey: String): Map<String, String> =
mapOf("Authorization" to "Bearer $apiKey")
}
internal object RealDebridApiClient {
private const val BASE_URL = "https://api.real-debrid.com/rest/1.0"
suspend fun validateApiKey(apiKey: String): Boolean =
httpRequestRaw(
method = "GET",
url = "$BASE_URL/user",
headers = authHeaders(apiKey.trim()),
body = "",
).status in 200..299
suspend fun addMagnet(apiKey: String, magnet: String): DebridApiResponse<RealDebridAddTorrentDto> =
formRequest(
method = "POST",
url = "$BASE_URL/torrents/addMagnet",
apiKey = apiKey,
fields = listOf("magnet" to magnet),
)
suspend fun getTorrentInfo(apiKey: String, id: String): DebridApiResponse<RealDebridTorrentInfoDto> =
request(
method = "GET",
url = "$BASE_URL/torrents/info/${encodePathSegment(id)}",
apiKey = apiKey,
)
suspend fun selectFiles(apiKey: String, id: String, files: String): DebridApiResponse<Unit> =
formRequest(
method = "POST",
url = "$BASE_URL/torrents/selectFiles/${encodePathSegment(id)}",
apiKey = apiKey,
fields = listOf("files" to files),
)
suspend fun unrestrictLink(apiKey: String, link: String): DebridApiResponse<RealDebridUnrestrictLinkDto> =
formRequest(
method = "POST",
url = "$BASE_URL/unrestrict/link",
apiKey = apiKey,
fields = listOf("link" to link),
)
suspend fun deleteTorrent(apiKey: String, id: String): DebridApiResponse<Unit> =
request(
method = "DELETE",
url = "$BASE_URL/torrents/delete/${encodePathSegment(id)}",
apiKey = apiKey,
)
private suspend inline fun <reified T> formRequest(
method: String,
url: String,
apiKey: String,
fields: List<Pair<String, String>>,
): DebridApiResponse<T> {
val body = fields.joinToString("&") { (key, value) ->
"${encodeFormValue(key)}=${encodeFormValue(value)}"
}
return request(
method = method,
url = url,
apiKey = apiKey,
body = body,
contentType = "application/x-www-form-urlencoded",
)
}
private suspend inline fun <reified T> request(
method: String,
url: String,
apiKey: String,
body: String = "",
contentType: String? = null,
): DebridApiResponse<T> {
val headers = authHeaders(apiKey) + listOfNotNull(
contentType?.let { "Content-Type" to it },
"Accept" to "application/json",
)
val response = httpRequestRaw(
method = method,
url = url,
headers = headers,
body = body,
)
return DebridApiResponse(
status = response.status,
body = response.decodeBody<T>(),
rawBody = response.body,
)
}
private fun authHeaders(apiKey: String): Map<String, String> =
mapOf("Authorization" to "Bearer $apiKey")
}
object DebridCredentialValidator {
suspend fun validateProvider(providerId: String, apiKey: String): Boolean {
val normalized = apiKey.trim()
if (normalized.isBlank()) return false
return when (DebridProviders.byId(providerId)?.id) {
DebridProviders.TORBOX_ID -> TorboxApiClient.validateApiKey(normalized)
DebridProviders.REAL_DEBRID_ID -> RealDebridApiClient.validateApiKey(normalized)
else -> false
}
}
}
private inline fun <reified T> RawHttpResponse.decodeBody(): T? {
if (body.isBlank() || T::class == Unit::class) return null
return try {
DebridApiJson.json.decodeFromString<T>(body)
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
}
}
private fun multipartFormBody(boundary: String, vararg fields: Pair<String, String>): String =
buildString {
fields.forEach { (name, value) ->
append("--").append(boundary).append("\r\n")
append("Content-Disposition: form-data; name=\"").append(name).append("\"\r\n\r\n")
append(value).append("\r\n")
}
append("--").append(boundary).append("--\r\n")
}

View file

@ -0,0 +1,94 @@
package com.nuvio.app.features.debrid
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class TorboxEnvelopeDto<T>(
val success: Boolean? = null,
val data: T? = null,
val error: String? = null,
val detail: String? = null,
)
@Serializable
internal data class TorboxCreateTorrentDataDto(
@SerialName("torrent_id") val torrentId: Int? = null,
val id: Int? = null,
val hash: String? = null,
@SerialName("auth_id") val authId: String? = null,
) {
fun resolvedTorrentId(): Int? = torrentId ?: id
}
@Serializable
internal data class TorboxTorrentDataDto(
val id: Int? = null,
val hash: String? = null,
val name: String? = null,
val files: List<TorboxTorrentFileDto>? = null,
)
@Serializable
internal data class TorboxTorrentFileDto(
val id: Int? = null,
val name: String? = null,
@SerialName("short_name") val shortName: String? = null,
@SerialName("absolute_path") val absolutePath: String? = null,
@SerialName("mimetype") val mimeType: String? = null,
val size: Long? = null,
) {
fun displayName(): String =
listOfNotNull(name, shortName, absolutePath)
.firstOrNull { it.isNotBlank() }
.orEmpty()
}
@Serializable
internal data class RealDebridAddTorrentDto(
val id: String? = null,
val uri: String? = null,
)
@Serializable
internal data class RealDebridTorrentInfoDto(
val id: String? = null,
val filename: String? = null,
@SerialName("original_filename") val originalFilename: String? = null,
val hash: String? = null,
val bytes: Long? = null,
@SerialName("original_bytes") val originalBytes: Long? = null,
val host: String? = null,
val split: Int? = null,
val progress: Int? = null,
val status: String? = null,
val files: List<RealDebridTorrentFileDto>? = null,
val links: List<String>? = null,
)
@Serializable
internal data class RealDebridTorrentFileDto(
val id: Int? = null,
val path: String? = null,
val bytes: Long? = null,
val selected: Int? = null,
) {
fun displayName(): String =
path.orEmpty().substringAfterLast('/').ifBlank { path.orEmpty() }
}
@Serializable
internal data class RealDebridUnrestrictLinkDto(
val id: String? = null,
val filename: String? = null,
val mimeType: String? = null,
val filesize: Long? = null,
val link: String? = null,
val host: String? = null,
val chunks: Int? = null,
val crc: Int? = null,
val download: String? = null,
val streamable: Int? = null,
val type: String? = null,
)

View file

@ -0,0 +1,169 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
internal class TorboxFileSelector {
fun selectFile(
files: List<TorboxTorrentFileDto>,
resolve: StreamClientResolve,
season: Int?,
episode: Int?,
): TorboxTorrentFileDto? {
val playable = files.filter { it.isPlayableVideo() }
if (playable.isEmpty()) return null
val episodePatterns = buildEpisodePatterns(
season = season ?: resolve.season,
episode = episode ?: resolve.episode,
)
val names = resolve.specificFileNames(episodePatterns)
if (names.isNotEmpty()) {
playable.firstNameMatch(names) { it.displayName() }?.let {
return it
}
}
if (episodePatterns.isNotEmpty()) {
playable.firstOrNull { file ->
val fileName = file.displayName().lowercase()
episodePatterns.any { pattern -> fileName.contains(pattern) }
}?.let {
return it
}
}
resolve.fileIdx?.let { fileIdx ->
files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let {
return it
}
if (fileIdx > 0) {
files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let {
return it
}
}
playable.firstOrNull { it.id == fileIdx }?.let {
return it
}
}
return playable.maxByOrNull { it.size ?: 0L }
}
private fun TorboxTorrentFileDto.isPlayableVideo(): Boolean {
val mime = mimeType.orEmpty().lowercase()
if (mime.startsWith("video/")) return true
return displayName().lowercase().hasVideoExtension()
}
}
internal class RealDebridFileSelector {
fun selectFile(
files: List<RealDebridTorrentFileDto>,
resolve: StreamClientResolve,
season: Int?,
episode: Int?,
): RealDebridTorrentFileDto? {
val playable = files.filter { it.isPlayableVideo() }
if (playable.isEmpty()) return null
val episodePatterns = buildEpisodePatterns(
season = season ?: resolve.season,
episode = episode ?: resolve.episode,
)
val names = resolve.specificFileNames(episodePatterns)
if (names.isNotEmpty()) {
playable.firstNameMatch(names) { it.displayName() }?.let {
return it
}
}
if (episodePatterns.isNotEmpty()) {
playable.firstOrNull { file ->
val fileName = file.displayName().lowercase()
episodePatterns.any { pattern -> fileName.contains(pattern) }
}?.let {
return it
}
}
resolve.fileIdx?.let { fileIdx ->
files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let {
return it
}
if (fileIdx > 0) {
files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let {
return it
}
}
playable.firstOrNull { it.id == fileIdx }?.let {
return it
}
}
return playable.maxByOrNull { it.bytes ?: 0L }
}
private fun RealDebridTorrentFileDto.isPlayableVideo(): Boolean =
displayName().lowercase().hasVideoExtension()
}
private fun String.normalizedName(): String =
substringAfterLast('/')
.substringBeforeLast('.')
.lowercase()
.replace(Regex("[^a-z0-9]+"), " ")
.trim()
private fun StreamClientResolve.specificFileNames(episodePatterns: List<String>): List<String> {
val raw = stream?.raw
return listOfNotNull(
filename,
raw?.filename,
raw?.parsed?.rawTitle?.takeIf { it.looksSpecificForSelection(episodePatterns) },
torrentName?.takeIf { it.looksSpecificForSelection(episodePatterns) },
)
.map { it.normalizedName() }
.filter { it.isNotBlank() }
.distinct()
}
private fun String.looksSpecificForSelection(episodePatterns: List<String>): Boolean {
val lower = lowercase()
return lower.hasVideoExtension() || episodePatterns.any { pattern -> lower.contains(pattern) }
}
private fun <T> List<T>.firstNameMatch(
names: List<String>,
displayName: (T) -> String,
): T? =
firstOrNull { item ->
val fileName = displayName(item).normalizedName()
names.any { name -> fileName.contains(name) || name.contains(fileName) }
}
private fun buildEpisodePatterns(season: Int?, episode: Int?): List<String> {
if (season == null || episode == null) return emptyList()
val seasonTwo = season.toString().padStart(2, '0')
val episodeTwo = episode.toString().padStart(2, '0')
return listOf(
"s${seasonTwo}e$episodeTwo",
"${season}x$episodeTwo",
"${season}x$episode",
)
}
private fun String.hasVideoExtension(): Boolean =
videoExtensions.any { endsWith(it) }
private val videoExtensions = setOf(
".mp4",
".mkv",
".webm",
".avi",
".mov",
".m4v",
".ts",
".m2ts",
".wmv",
".flv",
)

View file

@ -0,0 +1,83 @@
package com.nuvio.app.features.debrid
data class DebridProvider(
val id: String,
val displayName: String,
val shortName: String,
val visibleInUi: Boolean = true,
)
data class DebridServiceCredential(
val provider: DebridProvider,
val apiKey: String,
)
object DebridProviders {
const val TORBOX_ID = "torbox"
const val REAL_DEBRID_ID = "realdebrid"
val Torbox = DebridProvider(
id = TORBOX_ID,
displayName = "Torbox",
shortName = "TB",
)
val RealDebrid = DebridProvider(
id = REAL_DEBRID_ID,
displayName = "Real-Debrid",
shortName = "RD",
visibleInUi = false,
)
private val registered = listOf(Torbox, RealDebrid)
fun all(): List<DebridProvider> = registered
fun visible(): List<DebridProvider> = registered.filter { it.visibleInUi }
fun byId(id: String?): DebridProvider? {
val normalized = id?.trim()?.takeIf { it.isNotBlank() } ?: return null
return registered.firstOrNull { it.id.equals(normalized, ignoreCase = true) }
}
fun isSupported(id: String?): Boolean = byId(id) != null
fun isVisible(id: String?): Boolean = byId(id)?.visibleInUi == true
fun instantName(id: String?): String = "${displayName(id)} Instant"
fun addonId(id: String?): String =
"debrid:${byId(id)?.id ?: id?.trim().orEmpty().ifBlank { "unknown" }}"
fun displayName(id: String?): String =
byId(id)?.displayName ?: id.toFallbackDisplayName()
fun shortName(id: String?): String =
byId(id)?.shortName ?: id?.trim()?.takeIf { it.isNotBlank() }?.uppercase().orEmpty()
fun configuredServices(settings: DebridSettings): List<DebridServiceCredential> =
buildList {
settings.torboxApiKey.trim().takeIf { Torbox.visibleInUi && it.isNotBlank() }?.let { apiKey ->
add(DebridServiceCredential(Torbox, apiKey))
}
settings.realDebridApiKey.trim().takeIf { RealDebrid.visibleInUi && it.isNotBlank() }?.let { apiKey ->
add(DebridServiceCredential(RealDebrid, apiKey))
}
}
fun configuredSourceNames(settings: DebridSettings): List<String> =
configuredServices(settings).map { instantName(it.provider.id) }
private fun String?.toFallbackDisplayName(): String {
val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return "Debrid"
return value
.replace('-', ' ')
.replace('_', ' ')
.split(' ')
.filter { it.isNotBlank() }
.joinToString(" ") { part ->
part.lowercase().replaceFirstChar { it.titlecase() }
}
.ifBlank { "Debrid" }
}
}

View file

@ -0,0 +1,19 @@
package com.nuvio.app.features.debrid
data class DebridSettings(
val enabled: Boolean = false,
val torboxApiKey: String = "",
val realDebridApiKey: String = "",
val instantPlaybackPreparationLimit: Int = 0,
val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
) {
val hasAnyApiKey: Boolean
get() = DebridProviders.configuredServices(this).isNotEmpty()
}
internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2
internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5
internal fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int =
value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT)

View file

@ -0,0 +1,136 @@
package com.nuvio.app.features.debrid
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object DebridSettingsRepository {
private val _uiState = MutableStateFlow(DebridSettings())
val uiState: StateFlow<DebridSettings> = _uiState.asStateFlow()
private var hasLoaded = false
private var enabled = false
private var torboxApiKey = ""
private var realDebridApiKey = ""
private var instantPlaybackPreparationLimit = 0
private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
}
fun snapshot(): DebridSettings {
ensureLoaded()
return _uiState.value
}
fun setEnabled(value: Boolean) {
ensureLoaded()
if (value && !hasVisibleApiKey()) return
if (enabled == value) return
enabled = value
publish()
DebridSettingsStorage.saveEnabled(value)
}
fun setTorboxApiKey(value: String) {
ensureLoaded()
val normalized = value.trim()
if (torboxApiKey == normalized) return
torboxApiKey = normalized
disableIfNoKeys()
publish()
DebridSettingsStorage.saveTorboxApiKey(normalized)
}
fun setRealDebridApiKey(value: String) {
ensureLoaded()
val normalized = value.trim()
if (realDebridApiKey == normalized) return
realDebridApiKey = normalized
disableIfNoKeys()
publish()
DebridSettingsStorage.saveRealDebridApiKey(normalized)
}
fun setInstantPlaybackPreparationLimit(value: Int) {
ensureLoaded()
val normalized = normalizeDebridInstantPlaybackPreparationLimit(value)
if (instantPlaybackPreparationLimit == normalized) return
instantPlaybackPreparationLimit = normalized
publish()
DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized)
}
fun setStreamNameTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
if (streamNameTemplate == normalized) return
streamNameTemplate = normalized
publish()
DebridSettingsStorage.saveStreamNameTemplate(normalized)
}
fun setStreamDescriptionTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
if (streamDescriptionTemplate == normalized) return
streamDescriptionTemplate = normalized
publish()
DebridSettingsStorage.saveStreamDescriptionTemplate(normalized)
}
fun resetStreamTemplates() {
ensureLoaded()
streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
publish()
DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate)
DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate)
}
private fun disableIfNoKeys() {
if (!hasVisibleApiKey()) {
enabled = false
DebridSettingsStorage.saveEnabled(false)
}
}
private fun hasVisibleApiKey(): Boolean =
(DebridProviders.isVisible(DebridProviders.TORBOX_ID) && torboxApiKey.isNotBlank()) ||
(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID) && realDebridApiKey.isNotBlank())
private fun loadFromDisk() {
hasLoaded = true
torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty()
realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty()
enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey()
instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
)
streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.NAME_TEMPLATE
streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
publish()
}
private fun publish() {
_uiState.value = DebridSettings(
enabled = enabled,
torboxApiKey = torboxApiKey,
realDebridApiKey = realDebridApiKey,
instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
streamNameTemplate = streamNameTemplate,
streamDescriptionTemplate = streamDescriptionTemplate,
)
}
}

View file

@ -0,0 +1,20 @@
package com.nuvio.app.features.debrid
import kotlinx.serialization.json.JsonObject
internal expect object DebridSettingsStorage {
fun loadEnabled(): Boolean?
fun saveEnabled(enabled: Boolean)
fun loadTorboxApiKey(): String?
fun saveTorboxApiKey(apiKey: String)
fun loadRealDebridApiKey(): String?
fun saveRealDebridApiKey(apiKey: String)
fun loadInstantPlaybackPreparationLimit(): Int?
fun saveInstantPlaybackPreparationLimit(limit: Int)
fun loadStreamNameTemplate(): String?
fun saveStreamNameTemplate(template: String)
fun loadStreamDescriptionTemplate(): String?
fun saveStreamDescriptionTemplate(template: String)
fun exportToSyncPayload(): JsonObject
fun replaceFromSyncPayload(payload: JsonObject)
}

View file

@ -0,0 +1,143 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamClientResolveParsed
import com.nuvio.app.features.streams.StreamItem
class DebridStreamFormatter(
private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(),
) {
fun format(stream: StreamItem, settings: DebridSettings): StreamItem {
if (!stream.isDirectDebridStream) return stream
val values = buildValues(stream)
val formattedName = engine.render(settings.streamNameTemplate, values)
.lineSequence()
.joinToString(" ") { it.trim() }
.replace(Regex("\\s+"), " ")
.trim()
val formattedDescription = engine.render(settings.streamDescriptionTemplate, values)
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.joinToString("\n")
.trim()
return stream.copy(
name = formattedName.ifBlank { stream.name ?: DebridProviders.instantName(stream.clientResolve?.service) },
description = formattedDescription.ifBlank { stream.description ?: stream.title },
)
}
private fun buildValues(stream: StreamItem): Map<String, Any?> {
val resolve = stream.clientResolve
val raw = resolve?.stream?.raw
val parsed = raw?.parsed
val seasons = parsed?.seasons.orEmpty()
val episodes = parsed?.episodes.orEmpty()
val season = resolve?.season ?: seasons.singleOrFirstOrNull()
val episode = resolve?.episode ?: episodes.singleOrFirstOrNull()
val visualTags = buildList {
addAll(parsed?.hdr.orEmpty())
parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) }
}
val edition = parsed?.edition ?: buildEdition(parsed)
return linkedMapOf(
"stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title),
"stream.year" to parsed?.year,
"stream.season" to season,
"stream.episode" to episode,
"stream.seasons" to seasons,
"stream.episodes" to episodes,
"stream.seasonEpisode" to buildSeasonEpisodeList(season, episode, seasons, episodes),
"stream.formattedEpisodes" to formatEpisodes(episodes),
"stream.formattedSeasons" to formatSeasons(seasons),
"stream.resolution" to parsed?.resolution,
"stream.library" to false,
"stream.quality" to parsed?.quality,
"stream.visualTags" to visualTags,
"stream.audioTags" to parsed?.audio.orEmpty(),
"stream.audioChannels" to parsed?.channels.orEmpty(),
"stream.languages" to parsed?.languages.orEmpty(),
"stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) },
"stream.size" to (raw?.size ?: stream.behaviorHints.videoSize)?.let(::DebridTemplateBytes),
"stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes),
"stream.encode" to parsed?.codec?.uppercase(),
"stream.indexer" to (raw?.indexer ?: raw?.tracker),
"stream.network" to (parsed?.network ?: raw?.network),
"stream.releaseGroup" to parsed?.group,
"stream.duration" to parsed?.duration,
"stream.edition" to edition,
"stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename),
"stream.regexMatched" to null,
"stream.type" to streamType(resolve),
"service.cached" to resolve?.isCached,
"service.shortName" to serviceShortName(resolve),
"service.name" to serviceName(resolve),
"addon.name" to "Nuvio Direct Debrid",
)
}
private fun streamType(resolve: StreamClientResolve?): String =
when {
resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid"
resolve?.type.equals("torrent", ignoreCase = true) -> "p2p"
else -> resolve?.type.orEmpty()
}
private fun serviceShortName(resolve: StreamClientResolve?): String =
resolve?.serviceExtension?.takeIf { it.isNotBlank() }
?: DebridProviders.shortName(resolve?.service)
private fun serviceName(resolve: StreamClientResolve?): String =
DebridProviders.displayName(resolve?.service)
private fun buildEdition(parsed: StreamClientResolveParsed?): String? {
if (parsed == null) return null
return buildList {
if (parsed.extended == true) add("extended")
if (parsed.theatrical == true) add("theatrical")
if (parsed.remastered == true) add("remastered")
if (parsed.unrated == true) add("unrated")
}.joinToString(" ").takeIf { it.isNotBlank() }
}
private fun buildSeasonEpisodeList(
season: Int?,
episode: Int?,
seasons: List<Int>,
episodes: List<Int>,
): List<String> {
if (season != null && episode != null) return listOf("S${season.twoDigits()}E${episode.twoDigits()}")
if (seasons.isEmpty() || episodes.isEmpty()) return emptyList()
return seasons.flatMap { s -> episodes.map { e -> "S${s.twoDigits()}E${e.twoDigits()}" } }
}
private fun formatEpisodes(episodes: List<Int>): String =
episodes.joinToString(" | ") { "E${it.twoDigits()}" }
private fun formatSeasons(seasons: List<Int>): String =
seasons.joinToString(" | ") { "S${it.twoDigits()}" }
private fun List<Int>.singleOrFirstOrNull(): Int? =
singleOrNull() ?: firstOrNull()
private fun Int.twoDigits(): String = toString().padStart(2, '0')
private fun languageEmoji(language: String): String =
when (language.lowercase()) {
"en", "eng", "english" -> "GB"
"hi", "hin", "hindi" -> "IN"
"ml", "mal", "malayalam" -> "IN"
"ta", "tam", "tamil" -> "IN"
"te", "tel", "telugu" -> "IN"
"ja", "jpn", "japanese" -> "JP"
"ko", "kor", "korean" -> "KR"
"fr", "fre", "fra", "french" -> "FR"
"es", "spa", "spanish" -> "ES"
"de", "ger", "deu", "german" -> "DE"
"it", "ita", "italian" -> "IT"
"multi" -> "Multi"
else -> language
}
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.features.debrid
object DebridStreamFormatterDefaults {
const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{stream.resolution::exists[\"\"||\"Direct \"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}Instant"
const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}"
}

View file

@ -0,0 +1,394 @@
package com.nuvio.app.features.debrid
import kotlin.math.abs
import kotlin.math.roundToLong
internal data class DebridTemplateBytes(val value: Long)
class DebridStreamTemplateEngine {
fun render(template: String, values: Map<String, Any?>): String {
if (template.isEmpty()) return ""
val out = StringBuilder()
var index = 0
while (index < template.length) {
val start = template.indexOf('{', index)
if (start < 0) {
out.append(template.substring(index))
break
}
out.append(template.substring(index, start))
val end = findPlaceholderEnd(template, start + 1)
if (end < 0) {
out.append(template.substring(start))
break
}
val expression = template.substring(start + 1, end)
out.append(renderExpression(expression, values))
index = end + 1
}
return out.toString()
}
private fun renderExpression(expression: String, values: Map<String, Any?>): String {
val bracket = findTopLevelChar(expression, '[')
if (bracket >= 0 && expression.endsWith("]")) {
val condition = expression.substring(0, bracket)
val branches = parseBranches(expression.substring(bracket + 1, expression.length - 1))
val selected = if (evaluateCondition(condition, values)) branches.first else branches.second
return render(selected, values)
}
val tokens = splitOps(expression)
if (tokens.isEmpty()) return ""
var value: Any? = values[tokens.first()]
tokens.drop(1).forEach { op ->
value = applyTransform(value, op)
}
return valueToText(value)
}
private fun evaluateCondition(expression: String, values: Map<String, Any?>): Boolean {
val tokens = splitOps(expression).filter { it.isNotBlank() }
if (tokens.isEmpty()) return false
val groups = mutableListOf<MutableList<Boolean>>()
var currentGroup = mutableListOf<Boolean>()
var index = 0
while (index < tokens.size) {
when (tokens[index]) {
"or" -> {
groups += currentGroup
currentGroup = mutableListOf()
index++
}
"and" -> index++
else -> {
val field = tokens[index]
index++
val ops = mutableListOf<String>()
while (
index < tokens.size &&
tokens[index] != "and" &&
tokens[index] != "or" &&
!tokens[index].isFieldPath()
) {
ops += tokens[index]
index++
}
currentGroup += evaluateSingleCondition(values[field], ops)
}
}
}
groups += currentGroup
return groups.any { group -> group.isNotEmpty() && group.all { it } }
}
private fun evaluateSingleCondition(value: Any?, ops: List<String>): Boolean {
if (ops.isEmpty()) return isTruthy(value)
var result = false
var hasResult = false
ops.forEach { op ->
when {
op == "exists" -> {
result = exists(value)
hasResult = true
}
op == "istrue" -> {
result = if (hasResult) result else asBoolean(value) == true
hasResult = true
}
op == "isfalse" -> {
result = if (hasResult) !result else asBoolean(value) == false
hasResult = true
}
op.startsWith("~=") -> {
result = containsText(value, op.drop(2).trim())
hasResult = true
}
op.startsWith("~") -> {
result = containsText(value, op.drop(1).trim())
hasResult = true
}
op.startsWith("=") -> {
result = equalsText(value, op.drop(1).trim())
hasResult = true
}
op.startsWith(">=") -> {
result = compareNumber(value, op.drop(2)) { left, right -> left >= right }
hasResult = true
}
op.startsWith("<=") -> {
result = compareNumber(value, op.drop(2)) { left, right -> left <= right }
hasResult = true
}
op.startsWith(">") -> {
result = compareNumber(value, op.drop(1)) { left, right -> left > right }
hasResult = true
}
op.startsWith("<") -> {
result = compareNumber(value, op.drop(1)) { left, right -> left < right }
hasResult = true
}
}
}
return result
}
private fun applyTransform(value: Any?, op: String): Any? =
when {
op == "title" -> valueToText(value).titleCased()
op == "lower" -> valueToText(value).lowercase()
op == "upper" -> valueToText(value).uppercase()
op == "bytes" -> asNumber(value)?.let { formatBytes(it) }.orEmpty()
op == "time" -> asNumber(value)?.let { formatTime(it) }.orEmpty()
op.startsWith("join(") -> {
val separator = parseArgs(op).firstOrNull() ?: ", "
when (value) {
is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(separator)
else -> valueToText(value)
}
}
op.startsWith("replace(") -> {
val args = parseArgs(op)
if (args.size < 2) valueToText(value) else valueToText(value).replace(args[0], args[1])
}
else -> value
}
private fun findPlaceholderEnd(text: String, start: Int): Int {
var quote: Char? = null
var index = start
while (index < text.length) {
val char = text[index]
if (quote != null) {
if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null
} else {
when (char) {
'\'', '"' -> quote = char
'}' -> return index
}
}
index++
}
return -1
}
private fun findTopLevelChar(text: String, target: Char): Int {
var quote: Char? = null
var parenDepth = 0
text.forEachIndexed { index, char ->
if (quote != null) {
if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null
return@forEachIndexed
}
when (char) {
'\'', '"' -> quote = char
'(' -> parenDepth++
')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0)
target -> if (parenDepth == 0) return index
}
}
return -1
}
private fun splitOps(text: String): List<String> {
val tokens = mutableListOf<String>()
var quote: Char? = null
var parenDepth = 0
var start = 0
var index = 0
while (index < text.length) {
val char = text[index]
if (quote != null) {
if (char == quote && text.getOrNull(index - 1) != '\\') quote = null
index++
continue
}
when (char) {
'\'', '"' -> quote = char
'(' -> parenDepth++
')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0)
':' -> {
if (parenDepth == 0 && text.getOrNull(index + 1) == ':') {
tokens += text.substring(start, index).trim()
index += 2
start = index
continue
}
}
}
index++
}
tokens += text.substring(start).trim()
return tokens.filter { it.isNotEmpty() }
}
private fun parseBranches(text: String): Pair<String, String> {
val split = findBranchSeparator(text)
if (split < 0) return parseQuoted(text) to ""
return parseQuoted(text.substring(0, split)) to parseQuoted(text.substring(split + 2))
}
private fun findBranchSeparator(text: String): Int {
var quote: Char? = null
text.forEachIndexed { index, char ->
if (quote != null) {
if (char == quote && text.getOrNull(index - 1) != '\\') quote = null
return@forEachIndexed
}
when (char) {
'\'', '"' -> quote = char
'|' -> if (text.getOrNull(index + 1) == '|') return index
}
}
return -1
}
private fun parseArgs(op: String): List<String> {
val start = op.indexOf('(')
val end = op.lastIndexOf(')')
if (start < 0 || end <= start) return emptyList()
val body = op.substring(start + 1, end)
val args = mutableListOf<String>()
var quote: Char? = null
var argStart = 0
body.forEachIndexed { index, char ->
if (quote != null) {
if (char == quote && body.getOrNull(index - 1) != '\\') quote = null
return@forEachIndexed
}
when (char) {
'\'', '"' -> quote = char
',' -> {
args += parseQuoted(body.substring(argStart, index))
argStart = index + 1
}
}
}
args += parseQuoted(body.substring(argStart))
return args
}
private fun parseQuoted(raw: String): String {
val trimmed = raw.trim()
val unquoted = if (
trimmed.length >= 2 &&
((trimmed.first() == '"' && trimmed.last() == '"') ||
(trimmed.first() == '\'' && trimmed.last() == '\''))
) {
trimmed.substring(1, trimmed.length - 1)
} else {
trimmed
}
return unquoted
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\'", "'")
.replace("\\\\", "\\")
}
private fun String.isFieldPath(): Boolean =
startsWith("stream.") || startsWith("service.") || startsWith("addon.")
private fun exists(value: Any?): Boolean =
when (value) {
null -> false
is String -> value.isNotBlank()
is Iterable<*> -> value.any()
is Array<*> -> value.isNotEmpty()
else -> true
}
private fun isTruthy(value: Any?): Boolean =
when (value) {
is Boolean -> value
is DebridTemplateBytes -> value.value != 0L
is Number -> value.toDouble() != 0.0
else -> exists(value)
}
private fun asBoolean(value: Any?): Boolean? =
when (value) {
is Boolean -> value
is String -> value.toBooleanStrictOrNull()
else -> null
}
private fun asNumber(value: Any?): Double? =
when (value) {
is Number -> value.toDouble()
is DebridTemplateBytes -> value.value.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}
private fun compareNumber(value: Any?, rawTarget: String, compare: (Double, Double) -> Boolean): Boolean {
val left = asNumber(value) ?: return false
val right = rawTarget.trim().toDoubleOrNull() ?: return false
return compare(left, right)
}
private fun equalsText(value: Any?, target: String): Boolean =
when (value) {
is Iterable<*> -> value.any { valueToText(it).trim().equals(target, ignoreCase = true) }
else -> valueToText(value).trim().equals(target, ignoreCase = true)
}
private fun containsText(value: Any?, target: String): Boolean =
when (value) {
is Iterable<*> -> value.any { valueToText(it).contains(target, ignoreCase = true) }
else -> valueToText(value).contains(target, ignoreCase = true)
}
private fun valueToText(value: Any?): String =
when (value) {
null -> ""
is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(", ")
is DebridTemplateBytes -> formatBytes(value.value.toDouble())
is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString()
is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString()
else -> value.toString()
}
private fun String.titleCased(): String =
split(Regex("\\s+"))
.joinToString(" ") { word ->
if (word.isBlank()) {
word
} else {
word.lowercase().replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}
}
}
private fun formatBytes(value: Double): String {
val bytes = abs(value)
if (bytes < 1024.0) return "${value.toLong()} B"
val units = listOf("KB", "MB", "GB", "TB")
var current = bytes
var unitIndex = -1
while (current >= 1024.0 && unitIndex < units.lastIndex) {
current /= 1024.0
unitIndex++
}
val signed = if (value < 0) -current else current
return if (signed >= 10 || signed % 1.0 == 0.0) {
"${signed.toLong()} ${units[unitIndex]}"
} else {
val tenths = (signed * 10.0).roundToLong()
"${tenths / 10}.${abs(tenths % 10)} ${units[unitIndex]}"
}
}
private fun formatTime(value: Double): String {
val seconds = value.toLong()
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60
return when {
hours > 0 -> "${hours}h ${minutes}m"
minutes > 0 -> "${minutes}m ${remainingSeconds}s"
else -> "${remainingSeconds}s"
}
}
}

View file

@ -0,0 +1,38 @@
package com.nuvio.app.features.debrid
internal fun encodePathSegment(value: String): String =
percentEncode(value, spaceAsPlus = false)
internal fun encodeFormValue(value: String): String =
percentEncode(value, spaceAsPlus = true)
internal fun queryString(vararg pairs: Pair<String, String?>): String =
pairs
.mapNotNull { (key, value) ->
value?.let { "${encodePathSegment(key)}=${encodePathSegment(it)}" }
}
.joinToString("&")
private fun percentEncode(value: String, spaceAsPlus: Boolean): String = buildString {
val hex = "0123456789ABCDEF"
value.encodeToByteArray().forEach { byte ->
val code = byte.toInt() and 0xFF
val isUnreserved = (code in 'A'.code..'Z'.code) ||
(code in 'a'.code..'z'.code) ||
(code in '0'.code..'9'.code) ||
code == '-'.code ||
code == '.'.code ||
code == '_'.code ||
code == '~'.code
when {
isUnreserved -> append(code.toChar())
spaceAsPlus && code == 0x20 -> append('+')
else -> {
append('%')
append(hex[code shr 4])
append(hex[code and 0x0F])
}
}
}
}

View file

@ -0,0 +1,39 @@
package com.nuvio.app.features.debrid
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
class DirectDebridConfigEncoder {
@OptIn(ExperimentalEncodingApi::class)
fun encode(service: DebridServiceCredential): String {
val servicesJson = """{"service":"${service.provider.id.jsonEscaped()}","apiKey":"${service.apiKey.jsonEscaped()}"}"""
val json = """{"cachedOnly":true,"debridServices":[$servicesJson],"enableTorrent":false}"""
return Base64.Default.encode(json.encodeToByteArray())
}
fun encodeTorbox(apiKey: String): String =
encode(DebridServiceCredential(DebridProviders.Torbox, apiKey))
}
private fun String.jsonEscaped(): String = buildString {
this@jsonEscaped.forEach { char ->
when (char) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\b' -> append("\\b")
'\u000C' -> append("\\f")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> {
if (char.code < 0x20) {
append("\\u")
append(char.code.toString(16).padStart(4, '0'))
} else {
append(char)
}
}
}
}
}

View file

@ -0,0 +1,375 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.epochMs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.debrid_missing_api_key
import nuvio.composeapp.generated.resources.debrid_resolve_failed
import nuvio.composeapp.generated.resources.debrid_stream_stale
import org.jetbrains.compose.resources.getString
object DirectDebridPlaybackResolver {
private val torboxResolver = TorboxDirectDebridResolver()
private val realDebridResolver = RealDebridDirectDebridResolver()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()
private val resolvedCache = mutableMapOf<String, CachedDirectDebridResolve>()
private val inFlightResolves = mutableMapOf<String, Deferred<DirectDebridResolveResult>>()
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val cacheKey = stream.directDebridResolveCacheKey(season, episode)
if (cacheKey == null) {
return resolveUncached(stream, season, episode)
}
getCachedResult(cacheKey)?.let {
return it
}
var ownsResolve = false
val newResolve = scope.async(start = CoroutineStart.LAZY) {
resolveUncached(stream, season, episode)
}
val activeResolve = mutex.withLock {
getCachedResultLocked(cacheKey)?.let { cached ->
return@withLock null to cached
}
val existing = inFlightResolves[cacheKey]
if (existing != null) {
existing to null
} else {
inFlightResolves[cacheKey] = newResolve
ownsResolve = true
newResolve to null
}
}
activeResolve.second?.let {
newResolve.cancel()
return it
}
val deferred = activeResolve.first ?: return DirectDebridResolveResult.Error
if (!ownsResolve) newResolve.cancel()
if (ownsResolve) deferred.start()
return try {
val result = deferred.await()
if (ownsResolve && result is DirectDebridResolveResult.Success) {
mutex.withLock {
resolvedCache[cacheKey] = CachedDirectDebridResolve(
result = result,
cachedAtMs = epochMs(),
)
}
}
result
} finally {
if (ownsResolve) {
mutex.withLock {
if (inFlightResolves[cacheKey] === deferred) {
inFlightResolves.remove(cacheKey)
}
}
}
}
}
suspend fun cachedPlayableStream(stream: StreamItem, season: Int?, episode: Int?): StreamItem? {
val cacheKey = stream.directDebridResolveCacheKey(season, episode) ?: return null
return getCachedResult(cacheKey)
?.let { result -> stream.withResolvedDebridUrl(result) }
}
private suspend fun getCachedResult(cacheKey: String): DirectDebridResolveResult.Success? =
mutex.withLock { getCachedResultLocked(cacheKey) }
private fun getCachedResultLocked(cacheKey: String): DirectDebridResolveResult.Success? {
val cached = resolvedCache[cacheKey] ?: return null
val age = epochMs() - cached.cachedAtMs
return if (age in 0..DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS) {
cached.result
} else {
resolvedCache.remove(cacheKey)
null
}
}
private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult =
when (DebridProviders.byId(stream.clientResolve?.service)?.id) {
DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode)
DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode)
else -> DirectDebridResolveResult.Error
}
suspend fun resolveToPlayableStream(
stream: StreamItem,
season: Int?,
episode: Int?,
): DirectDebridPlayableResult {
if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) {
return DirectDebridPlayableResult.Success(stream)
}
return when (val result = resolve(stream, season, episode)) {
is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result))
DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey
DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale
DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error
}
}
}
private const val DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L
private data class CachedDirectDebridResolve(
val result: DirectDebridResolveResult.Success,
val cachedAtMs: Long,
)
sealed class DirectDebridPlayableResult {
data class Success(val stream: StreamItem) : DirectDebridPlayableResult()
data object MissingApiKey : DirectDebridPlayableResult()
data object Stale : DirectDebridPlayableResult()
data object Error : DirectDebridPlayableResult()
}
sealed class DirectDebridResolveResult {
data class Success(
val url: String,
val filename: String?,
val videoSize: Long?,
) : DirectDebridResolveResult()
data object MissingApiKey : DirectDebridResolveResult()
data object Stale : DirectDebridResolveResult()
data object Error : DirectDebridResolveResult()
}
fun DirectDebridPlayableResult.toastMessage(): String? =
when (this) {
is DirectDebridPlayableResult.Success -> null
DirectDebridPlayableResult.MissingApiKey -> runBlocking { getString(Res.string.debrid_missing_api_key) }
DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) }
DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) }
}
private class TorboxDirectDebridResolver(
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
?: buildMagnetUri(resolve)
?: run {
return DirectDebridResolveResult.Stale
}
return try {
val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet)
val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId()
?: return create.toFailureForCreate()
val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId)
if (!torrent.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val files = torrent.body?.data?.files.orEmpty()
val file = fileSelector.selectFile(files, resolve, season, episode)
?: run {
return DirectDebridResolveResult.Stale
}
val fileId = file.id
?: run {
return DirectDebridResolveResult.Stale
}
val link = TorboxApiClient.requestDownloadLink(
apiKey = apiKey,
torrentId = torrentId,
fileId = fileId,
)
if (!link.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val url = link.body?.data?.takeIf { it.isNotBlank() }
?: run {
return DirectDebridResolveResult.Stale
}
DirectDebridResolveResult.Success(
url = url,
filename = file.displayName().takeIf { it.isNotBlank() },
videoSize = file.size,
)
} catch (error: Exception) {
if (error is CancellationException) throw error
DirectDebridResolveResult.Error
}
}
private fun DebridApiResponse<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.toFailureForCreate(): DirectDebridResolveResult =
when (status) {
401, 403 -> DirectDebridResolveResult.Error
else -> DirectDebridResolveResult.Stale
}
}
private class RealDebridDirectDebridResolver(
private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot().realDebridApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
?: buildMagnetUri(resolve)
?: run {
return DirectDebridResolveResult.Stale
}
return try {
val add = RealDebridApiClient.addMagnet(apiKey, magnet)
val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() }
?: return add.toFailureForAdd()
var resolved = false
try {
val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
if (!infoBefore.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val filesBefore = infoBefore.body?.files.orEmpty()
val file = fileSelector.selectFile(
files = filesBefore,
resolve = resolve,
season = season,
episode = episode,
)
?: run {
return DirectDebridResolveResult.Stale
}
val fileId = file.id
?: run {
return DirectDebridResolveResult.Stale
}
val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString())
if (!select.isSuccessful && select.status != 202) {
return DirectDebridResolveResult.Stale
}
val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
if (!infoAfter.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val link = infoAfter.body?.firstDownloadLink()
?: run {
return DirectDebridResolveResult.Stale
}
val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link)
if (!unrestrict.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val url = unrestrict.body?.download?.takeIf { it.isNotBlank() }
?: run {
return DirectDebridResolveResult.Stale
}
resolved = true
DirectDebridResolveResult.Success(
url = url,
filename = unrestrict.body.filename?.takeIf { it.isNotBlank() }
?: file.displayName().takeIf { it.isNotBlank() },
videoSize = unrestrict.body.filesize ?: file.bytes,
)
} finally {
if (!resolved) {
runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) }
}
}
} catch (error: Exception) {
if (error is CancellationException) throw error
DirectDebridResolveResult.Error
}
}
private fun DebridApiResponse<RealDebridAddTorrentDto>.toFailureForAdd(): DirectDebridResolveResult =
when (status) {
401, 403 -> DirectDebridResolveResult.Error
else -> DirectDebridResolveResult.Stale
}
private fun RealDebridTorrentInfoDto.firstDownloadLink(): String? {
if (!status.equals("downloaded", ignoreCase = true)) return null
return links.orEmpty().firstOrNull { it.isNotBlank() }
}
}
private fun buildMagnetUri(resolve: StreamClientResolve): String? {
val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null
return buildString {
append("magnet:?xt=urn:btih:")
append(hash)
resolve.sources
.filter { it.isNotBlank() }
.forEach { source ->
append("&tr=")
append(encodePathSegment(source))
}
}
}
private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): String? {
val resolve = clientResolve ?: return null
val providerId = DebridProviders.byId(resolve.service)?.id ?: return null
val apiKey = when (providerId) {
DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey
DebridProviders.REAL_DEBRID_ID -> DebridSettingsRepository.snapshot().realDebridApiKey
else -> ""
}.trim().takeIf { it.isNotBlank() } ?: return null
val identity = resolve.infoHash
?: resolve.magnetUri
?: resolve.torrentName
?: resolve.filename
?: return null
return listOf(
providerId,
apiKey.stableFingerprint(),
identity.trim().lowercase(),
resolve.fileIdx?.toString().orEmpty(),
(resolve.filename ?: behaviorHints.filename).orEmpty().trim().lowercase(),
(season ?: resolve.season)?.toString().orEmpty(),
(episode ?: resolve.episode)?.toString().orEmpty(),
).joinToString("|")
}
private fun String.stableFingerprint(): String {
val hash = fold(1125899906842597L) { acc, char -> (acc * 31L) + char.code }
return hash.toULong().toString(16)
}
private fun StreamItem.withResolvedDebridUrl(result: DirectDebridResolveResult.Success): StreamItem =
copy(
url = result.url,
externalUrl = null,
behaviorHints = behaviorHints.mergeResolvedDebridHints(result),
)
private fun StreamBehaviorHints.mergeResolvedDebridHints(result: DirectDebridResolveResult.Success): StreamBehaviorHints =
copy(
filename = result.filename ?: filename,
videoSize = result.videoSize ?: videoSize,
)

View file

@ -0,0 +1,41 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamItem
object DirectDebridStreamFilter {
const val FALLBACK_SOURCE_NAME = "Direct Debrid"
fun filterInstant(streams: List<StreamItem>): List<StreamItem> =
streams
.filter(::isInstantCandidate)
.map { stream ->
val providerId = stream.clientResolve?.service
val sourceName = DebridProviders.instantName(providerId)
stream.copy(
name = stream.name ?: sourceName,
addonName = sourceName,
addonId = DebridProviders.addonId(providerId),
sourceName = stream.sourceName ?: FALLBACK_SOURCE_NAME,
)
}
.distinctBy { stream ->
listOf(
stream.clientResolve?.infoHash?.lowercase(),
stream.clientResolve?.fileIdx?.toString(),
stream.clientResolve?.filename,
stream.name,
stream.title,
).joinToString("|")
}
fun isInstantCandidate(stream: StreamItem): Boolean {
val resolve = stream.clientResolve ?: return false
return resolve.type.equals("debrid", ignoreCase = true) &&
DebridProviders.isSupported(resolve.service) &&
resolve.isCached == true
}
fun isDirectDebridSourceName(addonName: String): Boolean =
DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) }
}

View file

@ -0,0 +1,196 @@
package com.nuvio.app.features.debrid
import co.touchlab.kermit.Logger
import com.nuvio.app.features.player.PlayerSettingsUiState
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.epochMs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
object DirectDebridStreamPreparer {
private val log = Logger.withTag("DirectDebridPreparer")
private val budgetMutex = Mutex()
private val minuteStarts = ArrayDeque<Long>()
private val hourStarts = ArrayDeque<Long>()
suspend fun prepare(
streams: List<StreamItem>,
season: Int?,
episode: Int?,
playerSettings: PlayerSettingsUiState,
installedAddonNames: Set<String>,
onPrepared: (original: StreamItem, prepared: StreamItem) -> Unit,
) {
val settings = DebridSettingsRepository.snapshot()
val limit = settings.instantPlaybackPreparationLimit
if (!settings.enabled || limit <= 0 || !settings.hasAnyApiKey) return
val candidates = prioritizeCandidates(
streams = streams,
limit = limit,
playerSettings = playerSettings,
installedAddonNames = installedAddonNames,
)
for (stream in candidates) {
DirectDebridPlaybackResolver.cachedPlayableStream(stream, season, episode)?.let { cached ->
onPrepared(stream, cached)
continue
}
if (!consumeBackgroundBudget()) {
log.d { "Skipping instant playback preparation; local Torbox budget reached" }
return
}
try {
when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) {
is DirectDebridPlayableResult.Success -> {
if (result.stream.directPlaybackUrl != null) {
onPrepared(stream, result.stream)
}
}
else -> Unit
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
log.d(error) { "Instant playback preparation failed" }
}
}
}
internal fun prioritizeCandidates(
streams: List<StreamItem>,
limit: Int,
playerSettings: PlayerSettingsUiState,
installedAddonNames: Set<String>,
): List<StreamItem> {
if (limit <= 0) return emptyList()
val candidates = streams
.filter { it.isDirectDebridStream && it.directPlaybackUrl == null }
.distinctBy { it.preparationKey() }
if (candidates.isEmpty()) return emptyList()
val prioritized = mutableListOf<StreamItem>()
val autoPlaySelection = StreamAutoPlaySelector.selectAutoPlayStream(
streams = streams,
mode = playerSettings.streamAutoPlayMode,
regexPattern = playerSettings.streamAutoPlayRegex,
source = playerSettings.streamAutoPlaySource,
installedAddonNames = installedAddonNames,
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
)
if (autoPlaySelection?.isDirectDebridStream == true) {
candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() }
?.let(prioritized::add)
}
if (playerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) {
val regex = runCatching {
Regex(playerSettings.streamAutoPlayRegex.trim(), RegexOption.IGNORE_CASE)
}.getOrNull()
if (regex != null) {
candidates
.filter { candidate ->
prioritized.none { it.preparationKey() == candidate.preparationKey() } &&
regex.containsMatchIn(candidate.searchableText())
}
.forEach(prioritized::add)
}
}
candidates
.filter { candidate -> prioritized.none { it.preparationKey() == candidate.preparationKey() } }
.forEach(prioritized::add)
return prioritized.take(limit)
}
fun replacePreparedStream(
groups: List<AddonStreamGroup>,
original: StreamItem,
prepared: StreamItem,
): List<AddonStreamGroup> {
val key = original.preparationKey()
return groups.map { group ->
var changed = false
val updatedStreams = group.streams.map { stream ->
if (stream.preparationKey() == key) {
changed = true
prepared.copy(
addonName = stream.addonName,
addonId = stream.addonId,
sourceName = stream.sourceName,
)
} else {
stream
}
}
if (changed) group.copy(streams = updatedStreams) else group
}
}
private suspend fun consumeBackgroundBudget(): Boolean {
val now = epochMs()
return budgetMutex.withLock {
minuteStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS)
hourStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS)
if (
minuteStarts.size >= MAX_BACKGROUND_PREPARES_PER_MINUTE ||
hourStarts.size >= MAX_BACKGROUND_PREPARES_PER_HOUR
) {
false
} else {
minuteStarts.addLast(now)
hourStarts.addLast(now)
true
}
}
}
}
private const val MAX_BACKGROUND_PREPARES_PER_MINUTE = 6
private const val MAX_BACKGROUND_PREPARES_PER_HOUR = 30
private const val BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS = 60L * 1000L
private const val BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS = 60L * 60L * 1000L
private fun ArrayDeque<Long>.removeOlderThan(cutoffMs: Long) {
while (firstOrNull()?.let { it < cutoffMs } == true) {
removeFirst()
}
}
private fun StreamItem.preparationKey(): String {
val resolve = clientResolve
if (resolve != null) {
return listOf(
resolve.service.orEmpty().lowercase(),
resolve.infoHash.orEmpty().lowercase(),
resolve.fileIdx?.toString().orEmpty(),
resolve.filename.orEmpty().lowercase(),
resolve.torrentName.orEmpty().lowercase(),
resolve.magnetUri.orEmpty().lowercase(),
).joinToString("|")
}
return listOf(
addonId.lowercase(),
directPlaybackUrl.orEmpty().lowercase(),
name.orEmpty().lowercase(),
title.orEmpty().lowercase(),
).joinToString("|")
}
private fun StreamItem.searchableText(): String =
buildString {
append(addonName).append(' ')
append(name.orEmpty()).append(' ')
append(title.orEmpty()).append(' ')
append(description.orEmpty()).append(' ')
append(directPlaybackUrl.orEmpty())
}

View file

@ -0,0 +1,96 @@
package com.nuvio.app.features.debrid
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamParser
import kotlinx.coroutines.CancellationException
private const val DIRECT_DEBRID_TAG = "DirectDebridStreams"
data class DirectDebridStreamTarget(
val provider: DebridProvider,
val apiKey: String,
) {
val addonId: String = DebridProviders.addonId(provider.id)
val addonName: String = DebridProviders.instantName(provider.id)
}
object DirectDebridStreamSource {
private val log = Logger.withTag(DIRECT_DEBRID_TAG)
private val encoder = DirectDebridConfigEncoder()
private val formatter = DebridStreamFormatter()
fun configuredTargets(): List<DirectDebridStreamTarget> {
DebridSettingsRepository.ensureLoaded()
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled || DebridConfig.DIRECT_DEBRID_API_BASE_URL.isBlank()) return emptyList()
return DebridProviders.configuredServices(settings).map { credential ->
DirectDebridStreamTarget(
provider = credential.provider,
apiKey = credential.apiKey,
)
}
}
fun placeholders(): List<AddonStreamGroup> =
configuredTargets().map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}
suspend fun fetchProviderStreams(
type: String,
videoId: String,
target: DirectDebridStreamTarget,
): AddonStreamGroup {
val settings = DebridSettingsRepository.snapshot()
val baseUrl = DebridConfig.DIRECT_DEBRID_API_BASE_URL.trim().trimEnd('/')
if (!settings.enabled || baseUrl.isBlank()) {
return target.emptyGroup()
}
val credential = DebridServiceCredential(target.provider, target.apiKey)
val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json"
return try {
val payload = httpGetText(url)
val streams = StreamParser.parse(
payload = payload,
addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME,
addonId = target.addonId,
)
.let(DirectDebridStreamFilter::filterInstant)
.filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) }
.map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) }
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = streams,
isLoading = false,
)
} catch (error: Exception) {
if (error is CancellationException) throw error
log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" }
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = false,
error = error.message,
)
}
}
private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup =
AddonStreamGroup(
addonName = addonName,
addonId = addonId,
streams = emptyList(),
isLoading = false,
)
}

View file

@ -597,7 +597,7 @@ private fun EpisodeStreamsSubView(
) {
itemsIndexed(
items = streams,
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
) { _, stream ->
EpisodeSourceStreamRow(
stream = stream,

View file

@ -38,6 +38,10 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.AddonResource
import com.nuvio.app.features.addons.ManagedAddon
@ -802,7 +806,55 @@ fun PlayerScreen(
playerController?.seekTo(targetPositionMs)
}
fun resolveDebridForPlayer(
stream: StreamItem,
season: Int?,
episode: Int?,
onResolved: (StreamItem) -> Unit,
onStale: () -> Unit,
): Boolean {
if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) return false
scope.launch {
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = stream,
season = season,
episode = episode,
)
when (resolved) {
is DirectDebridPlayableResult.Success -> onResolved(resolved.stream)
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
if (resolved == DirectDebridPlayableResult.Stale) {
onStale()
}
}
}
}
return true
}
fun switchToSource(stream: StreamItem) {
if (
resolveDebridForPlayer(
stream = stream,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
onResolved = ::switchToSource,
onStale = {
val type = contentType ?: parentMetaType
val vid = activeVideoId
if (vid != null) {
PlayerStreamsRepository.loadSources(
type = type,
videoId = vid,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
forceRefresh = true,
)
}
},
)
) return
val url = stream.directPlaybackUrl ?: return
if (url == activeSourceUrl) return
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
@ -844,6 +896,26 @@ fun PlayerScreen(
}
fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
if (
resolveDebridForPlayer(
stream = stream,
season = episode.season,
episode = episode.episode,
onResolved = { resolvedStream ->
switchToEpisodeStream(resolvedStream, episode)
},
onStale = {
val type = contentType ?: parentMetaType
PlayerStreamsRepository.loadEpisodeStreams(
type = type,
videoId = episode.id,
season = episode.season,
episode = episode.episode,
forceRefresh = true,
)
},
)
) return
val url = stream.directPlaybackUrl ?: return
showNextEpisodeCard = false
showSourcesPanel = false

View file

@ -203,7 +203,7 @@ fun PlayerSourcesPanel(
) {
itemsIndexed(
items = streams,
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
) { _, stream ->
val isCurrent = isCurrentStream(
stream = stream,

View file

@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.pluginContentId
@ -152,6 +154,10 @@ object PlayerStreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value
val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.getEnabledScrapersForType(type)
@ -159,7 +165,7 @@ object PlayerStreamsRepository {
emptyList()
}
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
@ -185,7 +191,7 @@ object PlayerStreamsRepository {
)
}
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
@ -207,6 +213,13 @@ object PlayerStreamsRepository {
streams = emptyList(),
isLoading = true,
)
} + debridTargets.map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}
stateFlow.value = StreamsUiState(
groups = initialGroups,
@ -275,7 +288,18 @@ object PlayerStreamsRepository {
}
}
val jobs = addonJobs + pluginJobs
val debridJobs = debridTargets.map { target ->
async {
DirectDebridStreamSource.fetchProviderStreams(
type = type,
videoId = videoId,
target = target,
)
}
}
val jobs = addonJobs + pluginJobs + debridJobs
var debridPreparationLaunched = false
jobs.forEach { deferred ->
val result = deferred.await()
stateFlow.update { current ->
@ -293,6 +317,28 @@ object PlayerStreamsRepository {
} else null,
)
}
if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) {
debridPreparationLaunched = true
launch {
DirectDebridStreamPreparer.prepare(
streams = stateFlow.value.groups.flatMap { it.streams },
season = season,
episode = episode,
playerSettings = playerSettings,
installedAddonNames = installedAddonNames,
) { original, prepared ->
stateFlow.update { current ->
current.copy(
groups = DirectDebridStreamPreparer.replacePreparedStream(
groups = current.groups,
original = original,
prepared = prepared,
),
)
}
}
}
}
}
}
setJob(job)

View file

@ -0,0 +1,443 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT
import com.nuvio.app.features.debrid.DebridCredentialValidator
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.DebridSettings
import com.nuvio.app.features.debrid.DebridSettingsRepository
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_reset
import nuvio.composeapp.generated.resources.action_save
import nuvio.composeapp.generated.resources.action_validate
import nuvio.composeapp.generated.resources.settings_debrid_add_key_first
import nuvio.composeapp.generated.resources.settings_debrid_description_template
import nuvio.composeapp.generated.resources.settings_debrid_description_template_description
import nuvio.composeapp.generated.resources.settings_debrid_enable
import nuvio.composeapp.generated.resources.settings_debrid_enable_description
import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many
import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one
import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback
import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description
import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count
import nuvio.composeapp.generated.resources.settings_debrid_key_valid
import nuvio.composeapp.generated.resources.settings_debrid_key_invalid
import nuvio.composeapp.generated.resources.settings_debrid_name_template
import nuvio.composeapp.generated.resources.settings_debrid_name_template_description
import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description
import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback
import nuvio.composeapp.generated.resources.settings_debrid_section_formatting
import nuvio.composeapp.generated.resources.settings_debrid_section_providers
import nuvio.composeapp.generated.resources.settings_debrid_section_title
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.debridSettingsContent(
isTablet: Boolean,
settings: DebridSettings,
) {
item {
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_title),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = stringResource(Res.string.settings_debrid_enable),
description = stringResource(Res.string.settings_debrid_enable_description),
checked = settings.enabled,
enabled = settings.hasAnyApiKey,
isTablet = isTablet,
onCheckedChange = DebridSettingsRepository::setEnabled,
)
if (!settings.hasAnyApiKey) {
SettingsGroupDivider(isTablet = isTablet)
DebridInfoRow(
isTablet = isTablet,
text = stringResource(Res.string.settings_debrid_add_key_first),
)
}
}
}
}
item {
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_providers),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
DebridApiKeyRow(
isTablet = isTablet,
providerId = DebridProviders.TORBOX_ID,
title = DebridProviders.Torbox.displayName,
description = stringResource(Res.string.settings_debrid_provider_torbox_description),
value = settings.torboxApiKey,
onApiKeyCommitted = DebridSettingsRepository::setTorboxApiKey,
)
}
}
}
item {
var showPrepareCountDialog by rememberSaveable { mutableStateOf(false) }
val prepareLimit = settings.instantPlaybackPreparationLimit
val prepareEnabled = settings.enabled && prepareLimit > 0
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_instant_playback),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = stringResource(Res.string.settings_debrid_prepare_instant_playback),
description = stringResource(Res.string.settings_debrid_prepare_instant_playback_description),
checked = prepareEnabled,
enabled = settings.enabled && settings.hasAnyApiKey,
isTablet = isTablet,
onCheckedChange = { enabled ->
DebridSettingsRepository.setInstantPlaybackPreparationLimit(
if (enabled) DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT else 0,
)
},
)
if (prepareEnabled) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_debrid_prepare_stream_count),
description = prepareCountLabel(prepareLimit),
isTablet = isTablet,
onClick = { showPrepareCountDialog = true },
)
}
}
}
if (showPrepareCountDialog) {
DebridPrepareCountDialog(
selectedLimit = prepareLimit,
onLimitSelected = { limit ->
DebridSettingsRepository.setInstantPlaybackPreparationLimit(limit)
showPrepareCountDialog = false
},
onDismiss = { showPrepareCountDialog = false },
)
}
}
item {
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_formatting),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
DebridTemplateRow(
isTablet = isTablet,
title = stringResource(Res.string.settings_debrid_name_template),
description = stringResource(Res.string.settings_debrid_name_template_description),
value = settings.streamNameTemplate,
singleLine = true,
onTemplateCommitted = DebridSettingsRepository::setStreamNameTemplate,
)
SettingsGroupDivider(isTablet = isTablet)
DebridTemplateRow(
isTablet = isTablet,
title = stringResource(Res.string.settings_debrid_description_template),
description = stringResource(Res.string.settings_debrid_description_template_description),
value = settings.streamDescriptionTemplate,
singleLine = false,
onTemplateCommitted = DebridSettingsRepository::setStreamDescriptionTemplate,
)
SettingsGroupDivider(isTablet = isTablet)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 12.dp),
) {
TextButton(onClick = DebridSettingsRepository::resetStreamTemplates) {
Text(stringResource(Res.string.action_reset))
}
}
}
}
}
}
@Composable
private fun prepareCountLabel(limit: Int): String =
if (limit == 1) {
stringResource(Res.string.settings_debrid_prepare_count_one)
} else {
stringResource(Res.string.settings_debrid_prepare_count_many, limit)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun DebridPrepareCountDialog(
selectedLimit: Int,
onLimitSelected: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val options = listOf(1, 2, 3, 5)
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.settings_debrid_prepare_stream_count),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { limit ->
val isSelected = limit == selectedLimit
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onLimitSelected(limit) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = prepareCountLabel(limit),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun DebridApiKeyRow(
isTablet: Boolean,
providerId: String,
title: String,
description: String,
value: String,
onApiKeyCommitted: (String) -> Unit,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 16.dp else 14.dp
val scope = rememberCoroutineScope()
var draft by rememberSaveable(value) { mutableStateOf(value) }
var isValidating by rememberSaveable(providerId) { mutableStateOf(false) }
var validationMessage by rememberSaveable(providerId, value) { mutableStateOf<String?>(null) }
val normalizedDraft = draft.trim()
val validMessage = stringResource(Res.string.settings_debrid_key_valid)
val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SettingsSecretTextField(
value = draft,
onValueChange = {
draft = it
validationMessage = null
},
modifier = Modifier.fillMaxWidth(),
label = "$title API key",
)
validationMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
draft = normalizedDraft
onApiKeyCommitted(normalizedDraft)
},
enabled = normalizedDraft != value && !isValidating,
) {
Text(stringResource(Res.string.action_save))
}
TextButton(
onClick = {
scope.launch {
isValidating = true
val valid = runCatching {
DebridCredentialValidator.validateProvider(providerId, normalizedDraft)
}.getOrDefault(false)
validationMessage = if (valid) validMessage else invalidMessage
isValidating = false
}
},
enabled = normalizedDraft.isNotBlank() && !isValidating,
) {
Text(stringResource(Res.string.action_validate))
}
}
}
}
@Composable
private fun DebridTemplateRow(
isTablet: Boolean,
title: String,
description: String,
value: String,
singleLine: Boolean,
onTemplateCommitted: (String) -> Unit,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 16.dp else 14.dp
var draft by rememberSaveable(value) { mutableStateOf(value) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.fillMaxWidth(),
singleLine = singleLine,
minLines = if (singleLine) 1 else 4,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f),
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surface,
),
)
Row(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { onTemplateCommitted(draft) },
enabled = draft != value,
) {
Text(stringResource(Res.string.action_save))
}
}
}
}
@Composable
private fun DebridInfoRow(
isTablet: Boolean,
text: String,
) {
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 14.dp else 12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

View file

@ -1,10 +1,14 @@
package com.nuvio.app.features.settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudQueue
import androidx.compose.foundation.lazy.LazyListScope
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment
import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description
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 org.jetbrains.compose.resources.stringResource
@ -13,6 +17,7 @@ internal fun LazyListScope.integrationsContent(
isTablet: Boolean,
onTmdbClick: () -> Unit,
onMdbListClick: () -> Unit,
onDebridClick: () -> Unit,
) {
item {
SettingsSection(
@ -35,6 +40,14 @@ internal fun LazyListScope.integrationsContent(
isTablet = isTablet,
onClick = onMdbListClick,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.compose_settings_page_debrid),
description = stringResource(Res.string.settings_integrations_debrid_description),
icon = Icons.Rounded.CloudQueue,
isTablet = isTablet,
onClick = onDebridClick,
)
}
}
}

View file

@ -13,6 +13,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_account
import nuvio.composeapp.generated.resources.compose_settings_page_addons
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
@ -129,6 +130,11 @@ internal enum class SettingsPage(
category = SettingsCategory.General,
parentPage = Integrations,
),
Debrid(
titleRes = Res.string.compose_settings_page_debrid,
category = SettingsCategory.General,
parentPage = Integrations,
),
TraktAuthentication(
titleRes = Res.string.compose_settings_page_trakt,
category = SettingsCategory.Account,

View file

@ -59,6 +59,8 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleUiState
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.debrid.DebridSettings
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.mdblist.MdbListSettings
@ -127,6 +129,10 @@ fun SettingsScreen(
MdbListSettingsRepository.ensureLoaded()
MdbListSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val debridSettings by remember {
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val traktAuthUiState by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
@ -232,6 +238,7 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@ -279,6 +286,7 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@ -336,6 +344,7 @@ private fun MobileSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@ -544,6 +553,7 @@ private fun MobileSettingsScreen(
isTablet = false,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = false,
@ -553,6 +563,10 @@ private fun MobileSettingsScreen(
isTablet = false,
settings = mdbListSettings,
)
SettingsPage.Debrid -> debridSettingsContent(
isTablet = false,
settings = debridSettings,
)
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = false,
uiState = traktAuthUiState,
@ -637,6 +651,7 @@ private fun TabletSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@ -904,6 +919,7 @@ private fun TabletSettingsScreen(
isTablet = true,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = true,
@ -913,6 +929,10 @@ private fun TabletSettingsScreen(
isTablet = true,
settings = mdbListSettings,
)
SettingsPage.Debrid -> debridSettingsContent(
isTablet = true,
settings = debridSettings,
)
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = true,
uiState = traktAuthUiState,

View file

@ -34,14 +34,14 @@ object StreamAutoPlaySelector {
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.directPlaybackUrl != null
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable()
}
if (bingeGroupMatch != null) return bingeGroupMatch
}
return when (mode) {
StreamAutoPlayMode.MANUAL -> null
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.directPlaybackUrl != null }
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable() }
StreamAutoPlayMode.REGEX_MATCH -> {
val pattern = regexPattern.trim()
@ -61,7 +61,8 @@ object StreamAutoPlaySelector {
} else null
val matchingStreams = candidateStreams.filter { stream ->
val url = stream.directPlaybackUrl ?: return@filter false
if (!stream.isAutoPlayable()) return@filter false
val url = stream.directPlaybackUrl.orEmpty()
val searchableText = buildString {
append(stream.addonName).append(' ')
@ -81,8 +82,11 @@ object StreamAutoPlaySelector {
}
if (matchingStreams.isEmpty()) return null
matchingStreams.firstOrNull { it.directPlaybackUrl != null }
matchingStreams.firstOrNull { it.isAutoPlayable() }
}
}
}
private fun StreamItem.isAutoPlayable(): Boolean =
directPlaybackUrl != null || isDirectDebridStream
}

View file

@ -6,15 +6,18 @@ import org.jetbrains.compose.resources.getString
data class StreamItem(
val name: String? = null,
val title: String? = null,
val description: String? = null,
val url: String? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val externalUrl: String? = null,
val sources: List<String> = emptyList(),
val sourceName: String? = null,
val addonName: String,
val addonId: String,
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
) {
val streamLabel: String
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
@ -25,13 +28,18 @@ data class StreamItem(
val directPlaybackUrl: String?
get() = url ?: externalUrl
val isDirectDebridStream: Boolean
get() = clientResolve?.isDirectDebridCandidate == true
val isTorrentStream: Boolean
get() = !infoHash.isNullOrBlank() ||
get() = !isDirectDebridStream && (
!infoHash.isNullOrBlank() ||
url.isMagnetLink() ||
externalUrl.isMagnetLink()
)
val hasPlayableSource: Boolean
get() = url != null || infoHash != null || externalUrl != null
get() = url != null || infoHash != null || externalUrl != null || clientResolve != null
}
private fun String?.isMagnetLink(): Boolean =
@ -40,6 +48,7 @@ private fun String?.isMagnetLink(): Boolean =
data class StreamBehaviorHints(
val bingeGroup: String? = null,
val notWebReady: Boolean = false,
val videoHash: String? = null,
val videoSize: Long? = null,
val filename: String? = null,
val proxyHeaders: StreamProxyHeaders? = null,
@ -50,6 +59,71 @@ data class StreamProxyHeaders(
val response: Map<String, String>? = null,
)
data class StreamClientResolve(
val type: String? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val magnetUri: String? = null,
val sources: List<String> = emptyList(),
val torrentName: String? = null,
val filename: String? = null,
val mediaType: String? = null,
val mediaId: String? = null,
val mediaOnlyId: String? = null,
val title: String? = null,
val season: Int? = null,
val episode: Int? = null,
val service: String? = null,
val serviceIndex: Int? = null,
val serviceExtension: String? = null,
val isCached: Boolean? = null,
val stream: StreamClientResolveStream? = null,
) {
val isDirectDebridCandidate: Boolean
get() = type.equals("debrid", ignoreCase = true) &&
!service.isNullOrBlank() &&
isCached == true
}
data class StreamClientResolveStream(
val raw: StreamClientResolveRaw? = null,
)
data class StreamClientResolveRaw(
val torrentName: String? = null,
val filename: String? = null,
val size: Long? = null,
val folderSize: Long? = null,
val tracker: String? = null,
val indexer: String? = null,
val network: String? = null,
val parsed: StreamClientResolveParsed? = null,
)
data class StreamClientResolveParsed(
val rawTitle: String? = null,
val parsedTitle: String? = null,
val year: Int? = null,
val resolution: String? = null,
val seasons: List<Int> = emptyList(),
val episodes: List<Int> = emptyList(),
val quality: String? = null,
val hdr: List<String> = emptyList(),
val codec: String? = null,
val audio: List<String> = emptyList(),
val channels: List<String> = emptyList(),
val languages: List<String> = emptyList(),
val group: String? = null,
val network: String? = null,
val edition: String? = null,
val duration: Long? = null,
val bitDepth: String? = null,
val extended: Boolean? = null,
val theatrical: Boolean? = null,
val remastered: Boolean? = null,
val unrated: Boolean? = null,
)
data class AddonStreamGroup(
val addonName: String,
val addonId: String,

View file

@ -26,9 +26,10 @@ object StreamParser {
val url = obj.string("url")
val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl")
val clientResolve = obj.objectValue("clientResolve")?.toClientResolve()
// Must have at least one playable source
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val proxyHeaders = hintsObj
@ -36,16 +37,20 @@ object StreamParser {
?.toProxyHeaders()
StreamItem(
name = obj.string("name"),
title = obj.string("title"),
description = obj.string("description") ?: obj.string("title"),
url = url,
infoHash = infoHash,
fileIdx = obj.int("fileIdx"),
externalUrl = externalUrl,
sources = obj.stringList("sources"),
addonName = addonName,
addonId = addonId,
clientResolve = clientResolve,
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
videoHash = hintsObj?.string("videoHash"),
videoSize = hintsObj?.long("videoSize"),
filename = hintsObj?.string("filename"),
proxyHeaders = proxyHeaders,
@ -58,10 +63,14 @@ object StreamParser {
this[name]?.jsonPrimitive?.contentOrNull
private fun JsonObject.int(name: String): Int? =
this[name]?.jsonPrimitive?.intOrNull
this[name]?.jsonPrimitive?.let { primitive ->
primitive.intOrNull ?: primitive.contentOrNull?.toIntOrNull()
}
private fun JsonObject.long(name: String): Long? =
this[name]?.jsonPrimitive?.longOrNull
this[name]?.jsonPrimitive?.let { primitive ->
primitive.longOrNull ?: primitive.contentOrNull?.toLongOrNull()
}
private fun JsonObject.boolean(name: String): Boolean? =
this[name]?.jsonPrimitive?.booleanOrNull
@ -69,6 +78,16 @@ object StreamParser {
private fun JsonObject.objectValue(name: String): JsonObject? =
this[name] as? JsonObject
private fun JsonObject.stringList(name: String): List<String> =
(this[name] as? JsonArray)
?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
.orEmpty()
private fun JsonObject.intList(name: String): List<Int> =
(this[name] as? JsonArray)
?.mapNotNull { it.jsonPrimitive.intOrNull }
.orEmpty()
private fun JsonObject.stringMap(): Map<String, String> =
entries.mapNotNull { (key, value) ->
(value as? JsonPrimitive)?.contentOrNull
@ -87,4 +106,68 @@ object StreamParser {
response = responseHeaders,
)
}
private fun JsonObject.toClientResolve(): StreamClientResolve =
StreamClientResolve(
type = string("type"),
infoHash = string("infoHash"),
fileIdx = int("fileIdx"),
magnetUri = string("magnetUri"),
sources = stringList("sources"),
torrentName = string("torrentName"),
filename = string("filename"),
mediaType = string("mediaType"),
mediaId = string("mediaId"),
mediaOnlyId = string("mediaOnlyId"),
title = string("title"),
season = int("season"),
episode = int("episode"),
service = string("service"),
serviceIndex = int("serviceIndex"),
serviceExtension = string("serviceExtension"),
isCached = boolean("isCached"),
stream = objectValue("stream")?.toClientResolveStream(),
)
private fun JsonObject.toClientResolveStream(): StreamClientResolveStream =
StreamClientResolveStream(
raw = objectValue("raw")?.toClientResolveRaw(),
)
private fun JsonObject.toClientResolveRaw(): StreamClientResolveRaw =
StreamClientResolveRaw(
torrentName = string("torrentName"),
filename = string("filename"),
size = long("size"),
folderSize = long("folderSize"),
tracker = string("tracker"),
indexer = string("indexer"),
network = string("network"),
parsed = objectValue("parsed")?.toClientResolveParsed(),
)
private fun JsonObject.toClientResolveParsed(): StreamClientResolveParsed =
StreamClientResolveParsed(
rawTitle = string("raw_title"),
parsedTitle = string("parsed_title"),
year = int("year"),
resolution = string("resolution"),
seasons = intList("seasons"),
episodes = intList("episodes"),
quality = string("quality"),
hdr = stringList("hdr"),
codec = string("codec"),
audio = stringList("audio"),
channels = stringList("channels"),
languages = stringList("languages"),
group = string("group"),
network = string("network"),
edition = string("edition"),
duration = long("duration"),
bitDepth = string("bit_depth"),
extended = boolean("extended"),
theatrical = boolean("theatrical"),
remastered = boolean("remastered"),
unrated = boolean("unrated"),
)
}

View file

@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
@ -14,6 +16,7 @@ import com.nuvio.app.features.plugins.PluginRepositoryItem
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
@ -131,6 +134,7 @@ object StreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.getEnabledScrapersForType(type)
} else {
@ -141,7 +145,7 @@ object StreamsRepository {
groupByRepository = pluginUiState.groupStreamsByRepository,
)
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
@ -170,7 +174,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() && debridTargets.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
@ -194,6 +198,13 @@ object StreamsRepository {
streams = emptyList(),
isLoading = true,
)
} + debridTargets.map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}
_uiState.value = StreamsUiState(
requestToken = requestToken,
@ -211,13 +222,21 @@ object StreamsRepository {
.associate { it.addonId to it.scrapers.size }
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
val totalTasks = streamAddons.size + pluginRemainingByAddonId.values.sum()
val totalTasks = streamAddons.size +
pluginProviderGroups.sumOf { it.scrapers.size } +
debridTargets.size
val installedAddonNames = installedAddons
.map { it.displayTitle }
.toSet()
var autoSelectTriggered = false
var timeoutElapsed = false
var debridPreparationLaunched = false
fun publishCompletion(completion: StreamLoadCompletion) {
if (completions.trySend(completion).isFailure) {
log.d { "Ignoring late stream load completion after channel close" }
}
}
val timeoutJob = if (isAutoPlayEnabled) {
val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L
@ -271,7 +290,7 @@ object StreamsRepository {
log.d { "Fetching streams from: $url" }
val displayName = addon.addonName
val group = runCatching {
val group = runCatchingUnlessCancelled {
val payload = httpGetText(url)
StreamParser.parse(
payload = payload,
@ -299,7 +318,7 @@ object StreamsRepository {
)
},
)
completions.send(StreamLoadCompletion.Addon(group))
publishCompletion(StreamLoadCompletion.Addon(group))
}
}
@ -340,11 +359,25 @@ object StreamsRepository {
)
},
)
completions.send(completion)
publishCompletion(completion)
}
}
}
debridTargets.forEach { target ->
launch {
publishCompletion(
StreamLoadCompletion.Debrid(
DirectDebridStreamSource.fetchProviderStreams(
type = type,
videoId = videoId,
target = target,
),
),
)
}
}
repeat(totalTasks) {
when (val completion = completions.receive()) {
is StreamLoadCompletion.Addon -> {
@ -400,11 +433,46 @@ object StreamsRepository {
)
}
}
is StreamLoadCompletion.Debrid -> {
val result = completion.group
_uiState.update { current ->
val updated = current.groups.map { group ->
if (group.addonId == result.addonId) result else group
}
val anyLoading = updated.any { it.isLoading }
current.copy(
groups = updated,
isAnyLoading = anyLoading,
emptyStateReason = updated.toEmptyStateReason(anyLoading),
)
}
if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) {
debridPreparationLaunched = true
launch {
DirectDebridStreamPreparer.prepare(
streams = _uiState.value.groups.flatMap { it.streams },
season = season,
episode = episode,
playerSettings = playerSettings,
installedAddonNames = installedAddonNames,
) { original, prepared ->
_uiState.update { current ->
current.copy(
groups = DirectDebridStreamPreparer.replacePreparedStream(
groups = current.groups,
original = original,
prepared = prepared,
),
)
}
}
}
}
}
}
}
completions.close()
if (isAutoPlayEnabled && !autoSelectTriggered) {
autoSelectTriggered = true
val allStreams = _uiState.value.groups.flatMap { it.streams }
@ -493,6 +561,7 @@ private data class PluginProviderGroup(
private sealed interface StreamLoadCompletion {
data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion
data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion
data class PluginScraper(
val addonId: String,
val streams: List<StreamItem>,
@ -538,6 +607,15 @@ private fun List<AddonStreamGroup>.toEmptyStateReason(anyLoading: Boolean): Stre
}
}
private suspend fun <T> runCatchingUnlessCancelled(block: suspend () -> T): Result<T> =
try {
Result.success(block())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
Result.failure(error)
}
private fun PluginRuntimeResult.toStreamItem(
scraper: PluginScraper,
addonName: String = scraper.name,

View file

@ -864,7 +864,7 @@ private fun LazyListScope.streamSection(
StreamCard(
stream = stream,
onClick = {
if (stream.directPlaybackUrl != null || stream.isTorrentStream) {
if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) {
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
}
},
@ -896,7 +896,7 @@ internal fun streamCardRenderKey(
append(':')
append(itemIndex)
append(':')
append(stream.url ?: stream.infoHash ?: stream.streamLabel)
append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
}
// ---------------------------------------------------------------------------
@ -970,7 +970,7 @@ private fun StreamCard(
onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream
val cardShape = RoundedCornerShape(12.dp)
Row(
modifier = modifier

View file

@ -0,0 +1,148 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import kotlin.test.Test
import kotlin.test.assertEquals
class DebridFileSelectorTest {
@Test
fun `Torbox selector prefers exact file id`() {
val files = listOf(
TorboxTorrentFileDto(id = 1, name = "small.mkv", size = 1),
TorboxTorrentFileDto(id = 8, name = "target.mkv", size = 2),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(fileIdx = 8),
season = null,
episode = null,
)
assertEquals(8, selected?.id)
}
@Test
fun `Torbox selector prefers filename match before provider file id`() {
val files = listOf(
TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1),
TorboxTorrentFileDto(
id = 85,
name = "The Office US S01-S09/The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv",
size = 5_303_936_915,
),
TorboxTorrentFileDto(
id = 1,
name = "The Office US S01-S09/The.Office.US.S08E13.Jury.Duty.1080p.BluRay.Remux.mkv",
size = 5_859_312_140,
),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(
fileIdx = 1,
season = 1,
episode = 1,
filename = "The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv",
),
season = 1,
episode = 1,
)
assertEquals(85, selected?.id)
}
@Test
fun `Torbox selector treats fileIdx as source list index before provider file id`() {
val files = listOf(
TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1),
TorboxTorrentFileDto(id = 85, name = "Show.S01E01.mkv", size = 500),
TorboxTorrentFileDto(id = 1, name = "Show.S08E13.mkv", size = 900),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(fileIdx = 1),
season = null,
episode = null,
)
assertEquals(85, selected?.id)
}
@Test
fun `Torbox selector uses episode pattern before broad title`() {
val files = listOf(
TorboxTorrentFileDto(id = 1, name = "The.Office.US.S08E13.Jury.Duty.mkv", size = 900),
TorboxTorrentFileDto(id = 85, name = "The.Office.US.S01E01.Pilot.mkv", size = 500),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(
season = 1,
episode = 1,
title = "The Office",
),
season = 1,
episode = 1,
)
assertEquals(85, selected?.id)
}
@Test
fun `Torbox selector falls back to largest playable video`() {
val files = listOf(
TorboxTorrentFileDto(id = 1, name = "sample.txt", size = 999),
TorboxTorrentFileDto(id = 2, name = "episode.mkv", size = 200),
TorboxTorrentFileDto(id = 3, name = "episode-1080p.mp4", size = 500),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(),
season = null,
episode = null,
)
assertEquals(3, selected?.id)
}
@Test
fun `Real-Debrid selector matches episode pattern before largest file`() {
val files = listOf(
RealDebridTorrentFileDto(id = 1, path = "/Show.S01E01.mkv", bytes = 1_000),
RealDebridTorrentFileDto(id = 2, path = "/Show.S01E02.mkv", bytes = 2_000),
)
val selected = RealDebridFileSelector().selectFile(
files = files,
resolve = resolve(season = 1, episode = 1),
season = null,
episode = null,
)
assertEquals(1, selected?.id)
}
private fun resolve(
fileIdx: Int? = null,
season: Int? = null,
episode: Int? = null,
filename: String? = null,
title: String? = null,
): StreamClientResolve =
StreamClientResolve(
type = "debrid",
service = DebridProviders.TORBOX_ID,
isCached = true,
infoHash = "hash",
fileIdx = fileIdx,
filename = filename,
title = title,
season = season,
episode = episode,
)
}

View file

@ -0,0 +1,122 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamParser
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class DebridStreamFormatterTest {
private val formatter = DebridStreamFormatter()
@Test
fun `formats real client stream episode fields and behavior size`() {
val stream = StreamParser.parse(
payload = clientStreamPayload(),
addonName = "Torbox Instant",
addonId = "debrid:torbox",
).single()
val formatted = formatter.format(
stream = stream,
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
streamDescriptionTemplate = CLIENT_TEMPLATE,
),
)
val description = formatted.description.orEmpty()
assertEquals(0, stream.clientResolve?.fileIdx)
assertContains(description, "S05")
assertContains(description, "E02")
assertContains(description, "6.3 GB")
assertFalse(description.contains("6761331156"))
}
@Test
fun `formats season episode from parsed fields when top level resolve omits them`() {
val stream = StreamParser.parse(
payload = clientStreamPayload(includeTopLevelSeasonEpisode = false),
addonName = "Torbox Instant",
addonId = "debrid:torbox",
).single()
val formatted = formatter.format(
stream = stream,
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
streamDescriptionTemplate = CLIENT_TEMPLATE,
),
)
val description = formatted.description.orEmpty()
assertContains(description, "S05")
assertContains(description, "E02")
assertContains(description, "6.3 GB")
}
private fun clientStreamPayload(includeTopLevelSeasonEpisode: Boolean = true): String {
val seasonEpisode = if (includeTopLevelSeasonEpisode) {
"""
"season": 5,
"episode": 2,
""".trimIndent()
} else {
""
}
return """
{
"streams": [
{
"name": "TB 2160p cached",
"description": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"clientResolve": {
"type": "debrid",
"service": "torbox",
"isCached": true,
"infoHash": "cb7286fb422ed0643037523e7b09446734e9dbc4",
"sources": [],
"fileIdx": "0",
"filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"title": "The Boys",
"torrentName": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
$seasonEpisode
"stream": {
"raw": {
"parsed": {
"resolution": "2160p",
"quality": "WEB-DL",
"codec": "hevc",
"audio": ["Atmos", "Dolby Digital Plus"],
"channels": ["5.1"],
"hdr": ["DV", "HDR10+"],
"group": "Kitsune",
"seasons": [5],
"episodes": [2],
"raw_title": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv"
}
}
}
},
"behaviorHints": {
"filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"videoSize": 6761331156
}
}
]
}
""".trimIndent()
}
private companion object {
private const val CLIENT_TEMPLATE =
"{stream.title::exists[\"🍿 {stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year}) \"||\"\"]}\n" +
"{stream.season::>=0[\"🍂 S\"||\"\"]}{stream.season::<=9[\"0\"||\"\"]}{stream.season::>0[\"{stream.season} \"||\"\"]}{stream.episode::>=0[\"🎞️ E\"||\"\"]}{stream.episode::<=9[\"0\"||\"\"]}{stream.episode::>0[\"{stream.episode} \"||\"\"]}\n" +
"{stream.quality::exists[\"🎥 {stream.quality} \"||\"\"]}{stream.visualTags::exists[\"📺 {stream.visualTags::join(' | ')} \"||\"\"]}\n" +
"{stream.audioTags::exists[\"🎧 {stream.audioTags::join(' | ')} \"||\"\"]}{stream.audioChannels::exists[\"🔊 {stream.audioChannels::join(' | ')}\"||\"\"]}\n" +
"{stream.size::>0[\"📦 {stream.size::bytes} \"||\"\"]}{stream.encode::exists[\"🎞️ {stream.encode} \"||\"\"]}{stream.indexer::exists[\"📡{stream.indexer}\"||\"\"]}\n" +
"{service.cached::istrue[\"⚡Ready \"||\"\"]}{service.cached::isfalse[\"❌ Not Ready \"||\"\"]}{service.shortName::exists[\"({service.shortName}) \"||\"\"]}{stream.type::=Debrid[\"☁️ Debrid \"||\"\"]}🔍{addon.name}"
}
}

View file

@ -0,0 +1,45 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertEquals
class DebridStreamTemplateEngineTest {
private val engine = DebridStreamTemplateEngine()
@Test
fun `renders nested condition branches and transforms`() {
val rendered = engine.render(
"{stream.resolution::=2160p[\"4K {service.shortName} \"||\"\"]}{stream.title::title}",
mapOf(
"stream.resolution" to "2160p",
"stream.title" to "sample movie",
"service.shortName" to "RD",
),
)
assertEquals("4K RD Sample Movie", rendered)
}
@Test
fun `formats bytes and joins list values`() {
val rendered = engine.render(
"{stream.size::bytes} {stream.audioTags::join(' | ')}",
mapOf(
"stream.size" to 1_610_612_736L,
"stream.audioTags" to listOf("DTS", "Atmos"),
),
)
assertEquals("1.5 GB DTS | Atmos", rendered)
}
@Test
fun `renders Debrid size values as readable text while keeping numeric comparisons`() {
val rendered = engine.render(
"{stream.size::>0[\"{stream.size}\"||\"\"]}",
mapOf("stream.size" to DebridTemplateBytes(7_361_184_308L)),
)
assertEquals("6.9 GB", rendered)
}
}

View file

@ -0,0 +1,27 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertEquals
class DirectDebridConfigEncoderTest {
@Test
fun `encodes Torbox config exactly like TV`() {
val encoded = DirectDebridConfigEncoder().encodeTorbox("tb_key")
assertEquals(
"eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InRvcmJveCIsImFwaUtleSI6InRiX2tleSJ9XSwiZW5hYmxlVG9ycmVudCI6ZmFsc2V9",
encoded,
)
}
@Test
fun `escapes API key before base64 encoding`() {
val encoded = DirectDebridConfigEncoder().encode(
DebridServiceCredential(DebridProviders.RealDebrid, "rd\"key\\line"),
)
val expected = "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InJlYWxkZWJyaWQiLCJhcGlLZXkiOiJyZFwia2V5XFxsaW5lIn1dLCJlbmFibGVUb3JyZW50IjpmYWxzZX0="
assertEquals(expected, encoded)
}
}

View file

@ -0,0 +1,72 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamItem
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DirectDebridStreamFilterTest {
@Test
fun `keeps only cached supported debrid streams`() {
val torbox = stream(service = DebridProviders.TORBOX_ID, cached = true)
val uncached = stream(service = DebridProviders.TORBOX_ID, cached = false)
val unsupported = stream(service = "other", cached = true)
val torrent = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, type = "torrent")
val filtered = DirectDebridStreamFilter.filterInstant(listOf(torbox, uncached, unsupported, torrent))
assertEquals(1, filtered.size)
assertEquals("Torbox Instant", filtered.single().addonName)
assertEquals("debrid:torbox", filtered.single().addonId)
}
@Test
fun `dedupes by hash file and filename identity`() {
val first = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "ABC", fileIdx = 2)
val duplicate = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 2)
val otherFile = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 3)
val filtered = DirectDebridStreamFilter.filterInstant(listOf(first, duplicate, otherFile))
assertEquals(2, filtered.size)
}
@Test
fun `direct debrid stream is not treated as unsupported torrent`() {
val direct = stream(service = DebridProviders.TORBOX_ID, cached = true, infoHash = "hash")
val plainTorrent = StreamItem(
name = "Torrent",
infoHash = "hash",
addonName = "Addon",
addonId = "addon",
)
assertTrue(direct.isDirectDebridStream)
assertFalse(direct.isTorrentStream)
assertTrue(plainTorrent.isTorrentStream)
}
private fun stream(
service: String?,
cached: Boolean?,
type: String = "debrid",
infoHash: String = "hash",
fileIdx: Int = 1,
): StreamItem =
StreamItem(
name = "Stream",
addonName = "Direct Debrid",
addonId = "debrid",
clientResolve = StreamClientResolve(
type = type,
service = service,
isCached = cached,
infoHash = infoHash,
fileIdx = fileIdx,
filename = "video.mkv",
),
)
}

View file

@ -0,0 +1,70 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.player.PlayerSettingsUiState
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamItem
import kotlin.test.Test
import kotlin.test.assertEquals
class DirectDebridStreamPreparerTest {
@Test
fun `prioritizes autoplay direct debrid match before display order`() {
val first = directDebridStream(name = "1080p", infoHash = "hash-1")
val autoPlayMatch = directDebridStream(name = "2160p WEB", infoHash = "hash-2")
val remaining = directDebridStream(name = "720p", infoHash = "hash-3")
val selected = DirectDebridStreamPreparer.prioritizeCandidates(
streams = listOf(first, autoPlayMatch, remaining),
limit = 2,
playerSettings = PlayerSettingsUiState(
streamAutoPlayMode = StreamAutoPlayMode.REGEX_MATCH,
streamAutoPlayRegex = "2160p",
),
installedAddonNames = emptySet(),
)
assertEquals(listOf(autoPlayMatch, first), selected)
}
@Test
fun `skips already resolved and duplicate direct debrid candidates`() {
val unresolved = directDebridStream(name = "1080p", infoHash = "hash-1")
val duplicate = directDebridStream(name = "1080p Duplicate", infoHash = "HASH-1")
val alreadyResolved = directDebridStream(
name = "2160p",
infoHash = "hash-2",
url = "https://example.com/ready.mp4",
)
val selected = DirectDebridStreamPreparer.prioritizeCandidates(
streams = listOf(unresolved, duplicate, alreadyResolved),
limit = 5,
playerSettings = PlayerSettingsUiState(),
installedAddonNames = emptySet(),
)
assertEquals(listOf(unresolved), selected)
}
private fun directDebridStream(
name: String,
infoHash: String,
url: String? = null,
): StreamItem =
StreamItem(
name = name,
url = url,
addonName = "Torbox Instant",
addonId = "debrid:torbox",
clientResolve = StreamClientResolve(
type = "debrid",
service = DebridProviders.TORBOX_ID,
isCached = true,
infoHash = infoHash,
fileIdx = 1,
filename = "video.mkv",
),
)
}

View file

@ -145,16 +145,49 @@ class StreamAutoPlaySelectorTest {
assertNull(selected)
}
@Test
fun `first stream mode can select direct debrid candidate without resolved URL`() {
val directDebrid = stream(
addonName = "Torbox Instant",
url = null,
name = "TB Instant",
directDebrid = true,
)
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
streams = listOf(directDebrid),
mode = StreamAutoPlayMode.FIRST_STREAM,
regexPattern = "",
source = StreamAutoPlaySource.ALL_SOURCES,
installedAddonNames = emptySet(),
selectedAddons = emptySet(),
selectedPlugins = emptySet(),
)
assertEquals(directDebrid, selected)
}
private fun stream(
addonName: String,
url: String? = null,
name: String? = null,
bingeGroup: String? = null,
directDebrid: Boolean = false,
): StreamItem = StreamItem(
name = name,
url = url,
addonName = addonName,
addonId = addonName,
clientResolve = if (directDebrid) {
StreamClientResolve(
type = "debrid",
service = "torbox",
isCached = true,
infoHash = "hash",
)
} else {
null
},
behaviorHints = StreamBehaviorHints(
bingeGroup = bingeGroup,
),

View file

@ -119,4 +119,56 @@ class StreamParserTest {
assertEquals("video/mp4", responseHeaders["content-type"])
assertEquals("ok", responseHeaders["x-test"])
}
@Test
fun `parse keeps client resolve metadata without direct URL`() {
val streams = StreamParser.parse(
payload =
"""
{
"streams": [
{
"name": "Instant",
"clientResolve": {
"type": "debrid",
"infoHash": "abc123",
"fileIdx": 4,
"sources": ["udp://tracker.example"],
"torrentName": "Movie Pack",
"filename": "Movie.2024.2160p.mkv",
"service": "torbox",
"isCached": true,
"stream": {
"raw": {
"size": 1610612736,
"indexer": "Indexer",
"parsed": {
"parsed_title": "Movie",
"year": 2024,
"resolution": "2160p",
"hdr": ["DV"],
"audio": ["Atmos"],
"episodes": [1, 2],
"bit_depth": "10bit"
}
}
}
}
}
]
}
""".trimIndent(),
addonName = "Direct Debrid",
addonId = "debrid:torbox",
)
val stream = streams.single()
assertTrue(stream.isDirectDebridStream)
assertFalse(stream.isTorrentStream)
assertEquals("abc123", stream.clientResolve?.infoHash)
assertEquals(4, stream.clientResolve?.fileIdx)
assertEquals("udp://tracker.example", stream.clientResolve?.sources?.single())
assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution)
assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes)
}
}

View file

@ -0,0 +1,123 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncInt
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncInt
import com.nuvio.app.core.sync.encodeSyncString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import platform.Foundation.NSUserDefaults
actual object DebridSettingsStorage {
private const val enabledKey = "debrid_enabled"
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
private const val streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey)
actual fun saveTorboxApiKey(apiKey: String) {
saveString(torboxApiKeyKey, apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey)
actual fun saveRealDebridApiKey(apiKey: String) {
saveString(realDebridApiKeyKey, apiKey)
}
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
saveInt(instantPlaybackPreparationLimitKey, limit)
}
actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey)
actual fun saveStreamNameTemplate(template: String) {
saveString(streamNameTemplateKey, template)
}
actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey)
actual fun saveStreamDescriptionTemplate(template: String) {
saveString(streamDescriptionTemplateKey, template)
}
private fun loadBoolean(key: String): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val scopedKey = ProfileScopedKey.of(key)
return if (defaults.objectForKey(scopedKey) != null) {
defaults.boolForKey(scopedKey)
} else {
null
}
}
private fun saveBoolean(key: String, enabled: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key))
}
private fun loadInt(key: String): Int? {
val defaults = NSUserDefaults.standardUserDefaults
val scopedKey = ProfileScopedKey.of(key)
return if (defaults.objectForKey(scopedKey) != null) {
defaults.integerForKey(scopedKey).toInt()
} else {
null
}
}
private fun saveInt(key: String, value: Int) {
NSUserDefaults.standardUserDefaults.setInteger(value.toLong(), forKey = ProfileScopedKey.of(key))
}
private fun loadString(key: String): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key))
private fun saveString(key: String, value: String) {
NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(key))
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) }
loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
syncKeys.forEach { key ->
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key))
}
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
}

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=61
MARKETING_VERSION=0.1.19
MARKETING_VERSION=0.1.0