feat: init debrid direct streaming

This commit is contained in:
tapframe 2026-05-16 02:30:53 +05:30
parent 95db6db192
commit 9815adc2b6
42 changed files with 3006 additions and 67 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,112 @@
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.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
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 streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
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 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 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)) }
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.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
}

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,20 @@
<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">Instant cached Debrid streams</string>
<string name="settings_debrid_section_title">Direct Debrid</string>
<string name="settings_debrid_enable">Enable Debrid streams</string>
<string name="settings_debrid_enable_description">Show instant cached Debrid streams.</string>
<string name="settings_debrid_add_key_first">Add an API key before enabling Debrid streams.</string>
<string name="settings_debrid_section_providers">Account</string>
<string name="settings_debrid_provider_torbox_description">Use Torbox for instant cached streams.</string>
<string name="settings_debrid_section_formatting">Stream Formatting</string>
<string name="settings_debrid_name_template">Name template</string>
<string name="settings_debrid_name_template_description">Controls how Debrid stream names appear in source lists.</string>
<string name="settings_debrid_description_template">Description template</string>
<string name="settings_debrid_description_template_description">Controls the metadata lines shown under each Debrid stream.</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>
@ -1108,6 +1124,9 @@
<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="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,126 @@
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
resolve.fileIdx?.let { fileIdx ->
playable.firstOrNull { it.id == fileIdx }?.let { return it }
files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { return it }
}
val names = listOfNotNull(resolve.filename, resolve.title, resolve.torrentName)
.map { it.normalizedName() }
.filter { it.isNotBlank() }
if (names.isNotEmpty()) {
playable.firstOrNull { file ->
val fileName = file.displayName().normalizedName()
names.any { name -> fileName.contains(name) || name.contains(fileName) }
}?.let { return it }
}
val episodePatterns = buildEpisodePatterns(
season = season ?: resolve.season,
episode = episode ?: resolve.episode,
)
if (episodePatterns.isNotEmpty()) {
playable.firstOrNull { file ->
val fileName = file.displayName().lowercase()
episodePatterns.any { pattern -> fileName.contains(pattern) }
}?.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
resolve.fileIdx?.let { fileIdx ->
playable.firstOrNull { it.id == fileIdx }?.let { return it }
files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { return it }
}
val names = listOfNotNull(resolve.filename, resolve.title, resolve.torrentName)
.map { it.normalizedName() }
.filter { it.isNotBlank() }
if (names.isNotEmpty()) {
playable.firstOrNull { file ->
val fileName = file.displayName().normalizedName()
names.any { name -> fileName.contains(name) || name.contains(fileName) }
}?.let { return it }
}
val episodePatterns = buildEpisodePatterns(
season = season ?: resolve.season,
episode = episode ?: resolve.episode,
)
if (episodePatterns.isNotEmpty()) {
playable.firstOrNull { file ->
val fileName = file.displayName().lowercase()
episodePatterns.any { pattern -> fileName.contains(pattern) }
}?.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 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 preResolveLimit: 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_PRE_RESOLVE_DEFAULT_LIMIT = 2
internal const val DEBRID_PRE_RESOLVE_MAX_LIMIT = 5
internal fun normalizeDebridPreResolveLimit(value: Int): Int =
value.coerceIn(0, DEBRID_PRE_RESOLVE_MAX_LIMIT)

View file

@ -0,0 +1,122 @@
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 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 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()
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,
streamNameTemplate = streamNameTemplate,
streamDescriptionTemplate = streamDescriptionTemplate,
)
}
}

View file

@ -0,0 +1,19 @@
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 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,140 @@
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 season = resolve?.season
val episode = resolve?.episode
val seasons = parsed?.seasons.orEmpty()
val episodes = parsed?.episodes.orEmpty()
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),
"stream.folderSize" to raw?.folderSize,
"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 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,390 @@
package com.nuvio.app.features.debrid
import kotlin.math.abs
import kotlin.math.roundToLong
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 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 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 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,210 @@
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
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()
suspend fun resolve(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) 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
}
}
}
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)
?: 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)
?: return DirectDebridResolveResult.Stale
val fileId = file.id ?: 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() }
?: 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)
?: 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 file = fileSelector.selectFile(
files = infoBefore.body?.files.orEmpty(),
resolve = resolve,
season = season,
episode = episode,
) ?: return DirectDebridResolveResult.Stale
val fileId = file.id ?: 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()
?: return DirectDebridResolveResult.Stale
val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link)
if (!unrestrict.isSuccessful) return DirectDebridResolveResult.Stale
val url = unrestrict.body?.download?.takeIf { it.isNotBlank() }
?: 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.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,97 @@
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,7 @@ 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.DirectDebridStreamSource
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.pluginContentId
@ -152,6 +153,7 @@ object PlayerStreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.getEnabledScrapersForType(type)
@ -159,7 +161,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 +187,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 +209,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 +284,17 @@ 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
jobs.forEach { deferred ->
val result = deferred.await()
stateFlow.update { current ->

View file

@ -0,0 +1,294 @@
package com.nuvio.app.features.settings
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.lazy.LazyListScope
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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_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_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 {
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 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,
@ -69,6 +74,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 +102,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,7 @@ 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.DirectDebridStreamSource
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
@ -14,6 +15,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 +133,7 @@ object StreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.getEnabledScrapersForType(type)
} else {
@ -141,7 +144,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 +173,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 +197,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 +221,20 @@ 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
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 +288,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 +316,7 @@ object StreamsRepository {
)
},
)
completions.send(StreamLoadCompletion.Addon(group))
publishCompletion(StreamLoadCompletion.Addon(group))
}
}
@ -340,11 +357,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 +431,24 @@ 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),
)
}
}
}
}
completions.close()
if (isAutoPlayEnabled && !autoSelectTriggered) {
autoSelectTriggered = true
val allStreams = _uiState.value.groups.flatMap { it.streams }
@ -493,6 +537,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 +583,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,75 @@
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 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,
): StreamClientResolve =
StreamClientResolve(
type = "debrid",
service = DebridProviders.TORBOX_ID,
isCached = true,
infoHash = "hash",
fileIdx = fileIdx,
season = season,
episode = episode,
)
}

View file

@ -0,0 +1,36 @@
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)
}
}

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

@ -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,98 @@
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.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
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 streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
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 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 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)) }
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.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