feat(debrid): neutralize architecture

This commit is contained in:
tapframe 2026-05-21 11:39:54 +05:30
parent 782f65aaff
commit 075c5f8f51
23 changed files with 594 additions and 371 deletions

View file

@ -28,21 +28,20 @@ actual object DebridSettingsStorage {
private const val streamPreferencesKey = "debrid_stream_preferences"
private const val streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
streamMinimumQualityKey,
streamDolbyVisionFilterKey,
streamHdrFilterKey,
streamCodecFilterKey,
streamPreferencesKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
private fun syncKeys(): List<String> =
listOf(
enabledKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
streamMinimumQualityKey,
streamDolbyVisionFilterKey,
streamHdrFilterKey,
streamCodecFilterKey,
streamPreferencesKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
) + DebridProviders.all().map { providerApiKeyKey(it.id) }
private var preferences: SharedPreferences? = null
@ -56,16 +55,23 @@ actual object DebridSettingsStorage {
saveBoolean(enabledKey, enabled)
}
actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey)
actual fun loadProviderApiKey(providerId: String): String? =
loadString(providerApiKeyKey(providerId))
actual fun saveTorboxApiKey(apiKey: String) {
saveString(torboxApiKeyKey, apiKey)
actual fun saveProviderApiKey(providerId: String, apiKey: String) {
saveString(providerApiKeyKey(providerId), apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey)
actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID)
actual fun saveTorboxApiKey(apiKey: String) {
saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID)
actual fun saveRealDebridApiKey(apiKey: String) {
saveString(realDebridApiKeyKey, apiKey)
saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey)
}
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
@ -174,8 +180,11 @@ actual object DebridSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
DebridProviders.all().forEach { provider ->
loadProviderApiKey(provider.id)?.let {
put(providerApiKeyKey(provider.id), encodeSyncString(it))
}
}
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
@ -190,12 +199,15 @@ actual object DebridSettingsStorage {
actual fun replaceFromSyncPayload(payload: JsonObject) {
preferences?.edit()?.apply {
syncKeys.forEach { remove(ProfileScopedKey.of(it)) }
syncKeys().forEach { remove(ProfileScopedKey.of(it)) }
}?.apply()
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
DebridProviders.all().forEach { provider ->
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey ->
saveProviderApiKey(provider.id, apiKey)
}
}
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
@ -207,4 +219,14 @@ actual object DebridSettingsStorage {
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
private fun providerApiKeyKey(providerId: String): String {
val normalized = DebridProviders.byId(providerId)?.id
?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_")
return when (normalized) {
DebridProviders.TORBOX_ID -> torboxApiKeyKey
DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey
else -> "debrid_${normalized}_api_key"
}
}
}

View file

@ -595,7 +595,10 @@
<string name="settings_debrid_enable_description">Vis spillbare resultater fra tilkoblede kontoer.</string>
<string name="settings_debrid_add_key_first">Legg til en API-nøkkel først.</string>
<string name="settings_debrid_section_providers">Konto</string>
<string name="settings_debrid_provider_torbox_description">Koble til Torbox-kontoen din.</string>
<string name="settings_debrid_provider_description">Koble til %1$s-kontoen din.</string>
<string name="settings_debrid_dialog_title">%1$s API-nøkkel</string>
<string name="settings_debrid_dialog_subtitle">Skriv inn API-nøkkelen din for %1$s.</string>
<string name="settings_debrid_dialog_placeholder">Skriv inn %1$s API-nøkkel</string>
<string name="settings_debrid_section_instant_playback">Umiddelbar avspilling</string>
<string name="settings_debrid_prepare_instant_playback">Forbered lenker</string>
<string name="settings_debrid_prepare_instant_playback_description">Løs første kilder før avspilling starter.</string>

View file

@ -596,10 +596,10 @@
<string name="settings_debrid_enable_description">Show playable results from connected accounts.</string>
<string name="settings_debrid_add_key_first">Add an API key first.</string>
<string name="settings_debrid_section_providers">Account</string>
<string name="settings_debrid_provider_torbox_description">Connect your Torbox account.</string>
<string name="settings_debrid_dialog_title">Torbox API Key</string>
<string name="settings_debrid_dialog_subtitle">Enter your Torbox API key.</string>
<string name="settings_debrid_dialog_placeholder">Enter Torbox API key</string>
<string name="settings_debrid_provider_description">Connect your %1$s account.</string>
<string name="settings_debrid_dialog_title">%1$s API Key</string>
<string name="settings_debrid_dialog_subtitle">Enter your %1$s API key.</string>
<string name="settings_debrid_dialog_placeholder">Enter %1$s API key</string>
<string name="settings_debrid_not_set">Not set</string>
<string name="settings_debrid_section_instant_playback">Instant Playback</string>
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>

View file

@ -242,11 +242,7 @@ 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
}
return DebridProviderApis.apiFor(providerId)?.validateApiKey(normalized) == true
}
}

View file

@ -5,6 +5,7 @@ data class DebridProvider(
val displayName: String,
val shortName: String,
val visibleInUi: Boolean = true,
val capabilities: Set<DebridProviderCapability> = emptySet(),
)
data class DebridServiceCredential(
@ -12,6 +13,12 @@ data class DebridServiceCredential(
val apiKey: String,
)
enum class DebridProviderCapability {
ClientResolve,
LocalTorrentCacheCheck,
LocalTorrentResolve,
}
object DebridProviders {
const val TORBOX_ID = "torbox"
const val REAL_DEBRID_ID = "realdebrid"
@ -20,6 +27,11 @@ object DebridProviders {
id = TORBOX_ID,
displayName = "Torbox",
shortName = "TB",
capabilities = setOf(
DebridProviderCapability.ClientResolve,
DebridProviderCapability.LocalTorrentCacheCheck,
DebridProviderCapability.LocalTorrentResolve,
),
)
val RealDebrid = DebridProvider(
@ -27,6 +39,7 @@ object DebridProviders {
displayName = "Real-Debrid",
shortName = "RD",
visibleInUi = false,
capabilities = setOf(DebridProviderCapability.ClientResolve),
)
private val registered = listOf(Torbox, RealDebrid)
@ -56,13 +69,11 @@ object DebridProviders {
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))
}
registered.mapNotNull { provider ->
settings.apiKeyFor(provider.id)
.trim()
.takeIf { provider.visibleInUi && it.isNotBlank() }
?.let { apiKey -> DebridServiceCredential(provider, apiKey) }
}
fun configuredSourceNames(settings: DebridSettings): List<String> =
@ -81,3 +92,6 @@ object DebridProviders {
.ifBlank { "Debrid" }
}
}
fun DebridProvider.supports(capability: DebridProviderCapability): Boolean =
capability in capabilities

View file

@ -0,0 +1,198 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamItem
import kotlinx.coroutines.CancellationException
internal interface DebridProviderApi {
val provider: DebridProvider
suspend fun validateApiKey(apiKey: String): Boolean
suspend fun resolveClientStream(
stream: StreamItem,
apiKey: String,
season: Int?,
episode: Int?,
): DirectDebridResolveResult
}
internal object DebridProviderApis {
private val registered = listOf(
TorboxDebridProviderApi(),
RealDebridProviderApi(),
)
fun apiFor(providerId: String?): DebridProviderApi? {
val normalized = DebridProviders.byId(providerId)?.id ?: return null
return registered.firstOrNull { it.provider.id == normalized }
}
}
private class TorboxDebridProviderApi(
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
) : DebridProviderApi {
override val provider: DebridProvider = DebridProviders.Torbox
override suspend fun validateApiKey(apiKey: String): Boolean =
TorboxApiClient.validateApiKey(apiKey)
override suspend fun resolveClientStream(
stream: StreamItem,
apiKey: String,
season: Int?,
episode: Int?,
): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
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 class RealDebridProviderApi(
private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(),
) : DebridProviderApi {
override val provider: DebridProvider = DebridProviders.RealDebrid
override suspend fun validateApiKey(apiKey: String): Boolean =
RealDebridApiClient.validateApiKey(apiKey)
override suspend fun resolveClientStream(
stream: StreamItem,
apiKey: String,
season: Int?,
episode: Int?,
): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
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 filesBefore = infoBefore.body?.files.orEmpty()
val file = fileSelector.selectFile(
files = filesBefore,
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 buildMagnetUri(resolve: StreamClientResolve): String? {
val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null
return buildString {
append("magnet:?xt=urn:btih:")
append(hash)
resolve.sources
.mapNotNull { it.toTrackerUrlOrNull() }
.distinct()
.forEach { source ->
append("&tr=")
append(encodePathSegment(source))
}
}
}
private fun String.toTrackerUrlOrNull(): String? {
val value = trim()
if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null
return value.removePrefix("tracker:").trim().takeIf { it.isNotBlank() }
}
private fun DebridApiResponse<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.toFailureForCreate(): DirectDebridResolveResult =
when (status) {
401, 403 -> DirectDebridResolveResult.Error
409 -> DirectDebridResolveResult.NotCached
else -> DirectDebridResolveResult.Stale
}
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() }
}

View file

@ -4,8 +4,7 @@ import kotlinx.serialization.Serializable
data class DebridSettings(
val enabled: Boolean = false,
val torboxApiKey: String = "",
val realDebridApiKey: String = "",
val providerApiKeys: Map<String, String> = emptyMap(),
val instantPlaybackPreparationLimit: Int = 0,
val streamMaxResults: Int = 0,
val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT,
@ -17,11 +16,24 @@ data class DebridSettings(
val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
) {
val torboxApiKey: String
get() = apiKeyFor(DebridProviders.TORBOX_ID)
val realDebridApiKey: String
get() = apiKeyFor(DebridProviders.REAL_DEBRID_ID)
val hasAnyApiKey: Boolean
get() = DebridProviders.configuredServices(this).isNotEmpty()
val hasCustomStreamFormatting: Boolean
get() = streamNameTemplate.isNotBlank() || streamDescriptionTemplate.isNotBlank()
fun apiKeyFor(providerId: String?): String {
val normalized = DebridProviders.byId(providerId)?.id
?: providerId?.trim()?.lowercase()
?: return ""
return providerApiKeys[normalized].orEmpty()
}
}
const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2

View file

@ -21,8 +21,7 @@ object DebridSettingsRepository {
private var hasLoaded = false
private var enabled = false
private var torboxApiKey = ""
private var realDebridApiKey = ""
private var providerApiKeys = emptyMap<String, String>()
private var instantPlaybackPreparationLimit = 0
private var streamMaxResults = 0
private var streamSortMode = DebridStreamSortMode.DEFAULT
@ -57,24 +56,27 @@ object DebridSettingsRepository {
DebridSettingsStorage.saveEnabled(value)
}
fun setTorboxApiKey(value: String) {
fun setProviderApiKey(providerId: String, value: String) {
ensureLoaded()
val provider = DebridProviders.byId(providerId) ?: return
val normalized = value.trim()
if (torboxApiKey == normalized) return
torboxApiKey = normalized
if (providerApiKeys[provider.id].orEmpty() == normalized) return
providerApiKeys = if (normalized.isBlank()) {
providerApiKeys - provider.id
} else {
providerApiKeys + (provider.id to normalized)
}
disableIfNoKeys()
publish()
DebridSettingsStorage.saveTorboxApiKey(normalized)
DebridSettingsStorage.saveProviderApiKey(provider.id, normalized)
}
fun setTorboxApiKey(value: String) {
setProviderApiKey(DebridProviders.TORBOX_ID, value)
}
fun setRealDebridApiKey(value: String) {
ensureLoaded()
val normalized = value.trim()
if (realDebridApiKey == normalized) return
realDebridApiKey = normalized
disableIfNoKeys()
publish()
DebridSettingsStorage.saveRealDebridApiKey(normalized)
setProviderApiKey(DebridProviders.REAL_DEBRID_ID, value)
}
fun setInstantPlaybackPreparationLimit(value: Int) {
@ -200,13 +202,20 @@ object DebridSettingsRepository {
}
private fun hasVisibleApiKey(): Boolean =
(DebridProviders.isVisible(DebridProviders.TORBOX_ID) && torboxApiKey.isNotBlank()) ||
(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID) && realDebridApiKey.isNotBlank())
DebridProviders.visible().any { provider ->
providerApiKeys[provider.id].orEmpty().isNotBlank()
}
private fun loadFromDisk() {
hasLoaded = true
torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty()
realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty()
providerApiKeys = DebridProviders.all()
.mapNotNull { provider ->
DebridSettingsStorage.loadProviderApiKey(provider.id)
?.trim()
?.takeIf { it.isNotBlank() }
?.let { apiKey -> provider.id to apiKey }
}
.toMap()
enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey()
instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
@ -255,8 +264,7 @@ object DebridSettingsRepository {
private fun publish() {
_uiState.value = DebridSettings(
enabled = enabled,
torboxApiKey = torboxApiKey,
realDebridApiKey = realDebridApiKey,
providerApiKeys = providerApiKeys,
instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
streamMaxResults = streamMaxResults,
streamSortMode = streamSortMode,

View file

@ -5,6 +5,8 @@ import kotlinx.serialization.json.JsonObject
internal expect object DebridSettingsStorage {
fun loadEnabled(): Boolean?
fun saveEnabled(enabled: Boolean)
fun loadProviderApiKey(providerId: String): String?
fun saveProviderApiKey(providerId: String, apiKey: String)
fun loadTorboxApiKey(): String?
fun saveTorboxApiKey(apiKey: String)
fun loadRealDebridApiKey(): String?

View file

@ -43,15 +43,15 @@ object DebridStreamPresentation {
return isAddonDebridCandidate && (isDirectDebridStream || (
isTorrentStream &&
status != null &&
status.providerId == DebridProviders.TORBOX_ID &&
DebridProviders.byId(status.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true &&
status.state != StreamDebridCacheState.CHECKING
))
}
private val StreamItem.isUncachedDebridStream: Boolean
get() = isInstalledAddonStream &&
debridCacheStatus?.providerId == DebridProviders.TORBOX_ID &&
debridCacheStatus.state == StreamDebridCacheState.NOT_CACHED
DebridProviders.byId(debridCacheStatus?.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true &&
debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED
private fun applyLimits(
streams: List<Pair<StreamItem, DebridStreamFacts>>,

View file

@ -23,9 +23,7 @@ import nuvio.composeapp.generated.resources.debrid_stream_stale
import org.jetbrains.compose.resources.getString
object DirectDebridPlaybackResolver {
private val torboxResolver = TorboxDirectDebridResolver()
private val torboxAddonStreamResolver = TorboxAddonStreamResolver()
private val realDebridResolver = RealDebridDirectDebridResolver()
private val localAddonStreamResolver = LocalDebridAddonStreamResolver()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()
private val resolvedCache = mutableMapOf<String, CachedDirectDebridResolve>()
@ -115,27 +113,29 @@ object DirectDebridPlaybackResolver {
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled) return false
if (stream.needsLocalDebridResolve) {
return stream.isInstalledAddonStream && settings.torboxApiKey.isNotBlank()
return stream.isInstalledAddonStream && localTorrentResolveCredential(settings) != null
}
if (!stream.isInstalledAddonStream || !stream.isDirectDebridStream || stream.playableDirectUrl != null) {
return false
}
return when (DebridProviders.byId(stream.clientResolve?.service)?.id) {
DebridProviders.TORBOX_ID -> settings.torboxApiKey.isNotBlank()
DebridProviders.REAL_DEBRID_ID -> settings.realDebridApiKey.isNotBlank()
else -> false
}
val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id ?: return false
return settings.apiKeyFor(providerId).isNotBlank() && DebridProviderApis.apiFor(providerId) != null
}
private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult =
when {
stream.needsLocalDebridResolve -> torboxAddonStreamResolver.resolve(stream, season, episode)
else -> 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
}
private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
if (stream.needsLocalDebridResolve) {
return localAddonStreamResolver.resolve(stream, season, episode)
}
val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id
?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot()
.apiKeyFor(providerId)
.trim()
.takeIf { it.isNotBlank() }
?: return DirectDebridResolveResult.MissingApiKey
val api = DebridProviderApis.apiFor(providerId) ?: return DirectDebridResolveResult.Error
return api.resolveClientStream(stream, apiKey, season, episode)
}
suspend fun resolveToPlayableStream(
stream: StreamItem,
@ -192,21 +192,23 @@ fun DirectDebridPlayableResult.toastMessage(): String? =
DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) }
}
private class TorboxAddonStreamResolver(
private class LocalDebridAddonStreamResolver(
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val account = localTorrentResolveCredential() ?: return DirectDebridResolveResult.MissingApiKey
val apiKey = account.apiKey.trim()
val hash = stream.infoHash?.trim()?.lowercase()
if (stream.debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED) {
return DirectDebridResolveResult.NotCached
}
if (!hash.isNullOrBlank() && stream.debridCacheStatus?.state != StreamDebridCacheState.CACHED) {
when (TorboxAvailabilityService.isCached(hash)) {
if (
!hash.isNullOrBlank() &&
stream.debridCacheStatus?.state != StreamDebridCacheState.CACHED &&
account.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck)
) {
when (LocalDebridService.isCached(account, hash)) {
false -> return DirectDebridResolveResult.NotCached
true, null -> Unit
}
@ -214,8 +216,22 @@ private class TorboxAddonStreamResolver(
val magnet = DebridMagnetBuilder.fromStream(stream)
?: return DirectDebridResolveResult.Stale
val resolve = stream.toResolveMetadata(season, episode)
val resolve = stream.toResolveMetadata(season, episode, account.provider.id)
return when (account.provider.id) {
DebridProviders.TORBOX_ID -> resolveTorbox(stream, resolve, apiKey, magnet, season, episode)
else -> DirectDebridResolveResult.Error
}
}
private suspend fun resolveTorbox(
stream: StreamItem,
resolve: StreamClientResolve,
apiKey: String,
magnet: String,
season: Int?,
episode: Int?,
): DirectDebridResolveResult {
return try {
val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet)
val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId()
@ -254,184 +270,20 @@ private class TorboxAddonStreamResolver(
}
}
private class TorboxDirectDebridResolver(
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
?: buildMagnetUri(resolve)
?: run {
return DirectDebridResolveResult.Stale
}
return try {
val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet)
val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId()
?: return create.toFailureForCreate()
val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId)
if (!torrent.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val files = torrent.body?.data?.files.orEmpty()
val file = fileSelector.selectFile(files, resolve, season, episode)
?: run {
return DirectDebridResolveResult.Stale
}
val fileId = file.id
?: run {
return DirectDebridResolveResult.Stale
}
val link = TorboxApiClient.requestDownloadLink(
apiKey = apiKey,
torrentId = torrentId,
fileId = fileId,
)
if (!link.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val url = link.body?.data?.takeIf { it.isNotBlank() }
?: run {
return DirectDebridResolveResult.Stale
}
DirectDebridResolveResult.Success(
url = url,
filename = file.displayName().takeIf { it.isNotBlank() },
videoSize = file.size,
)
} catch (error: Exception) {
if (error is CancellationException) throw error
DirectDebridResolveResult.Error
}
}
}
private class RealDebridDirectDebridResolver(
private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot().realDebridApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
?: buildMagnetUri(resolve)
?: run {
return DirectDebridResolveResult.Stale
}
return try {
val add = RealDebridApiClient.addMagnet(apiKey, magnet)
val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() }
?: return add.toFailureForAdd()
var resolved = false
try {
val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
if (!infoBefore.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val filesBefore = infoBefore.body?.files.orEmpty()
val file = fileSelector.selectFile(
files = filesBefore,
resolve = resolve,
season = season,
episode = episode,
)
?: run {
return DirectDebridResolveResult.Stale
}
val fileId = file.id
?: run {
return DirectDebridResolveResult.Stale
}
val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString())
if (!select.isSuccessful && select.status != 202) {
return DirectDebridResolveResult.Stale
}
val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
if (!infoAfter.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val link = infoAfter.body?.firstDownloadLink()
?: run {
return DirectDebridResolveResult.Stale
}
val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link)
if (!unrestrict.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val url = unrestrict.body?.download?.takeIf { it.isNotBlank() }
?: run {
return DirectDebridResolveResult.Stale
}
resolved = true
DirectDebridResolveResult.Success(
url = url,
filename = unrestrict.body.filename?.takeIf { it.isNotBlank() }
?: file.displayName().takeIf { it.isNotBlank() },
videoSize = unrestrict.body.filesize ?: file.bytes,
)
} finally {
if (!resolved) {
runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) }
}
}
} catch (error: Exception) {
if (error is CancellationException) throw error
DirectDebridResolveResult.Error
}
}
private fun DebridApiResponse<RealDebridAddTorrentDto>.toFailureForAdd(): DirectDebridResolveResult =
when (status) {
401, 403 -> DirectDebridResolveResult.Error
else -> DirectDebridResolveResult.Stale
}
private fun RealDebridTorrentInfoDto.firstDownloadLink(): String? {
if (!status.equals("downloaded", ignoreCase = true)) return null
return links.orEmpty().firstOrNull { it.isNotBlank() }
}
}
private fun buildMagnetUri(resolve: StreamClientResolve): String? {
val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null
return buildString {
append("magnet:?xt=urn:btih:")
append(hash)
resolve.sources
.mapNotNull { it.toTrackerUrlOrNull() }
.distinct()
.forEach { source ->
append("&tr=")
append(encodePathSegment(source))
}
}
}
private fun String.toTrackerUrlOrNull(): String? {
val value = trim()
if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null
return value.removePrefix("tracker:").trim().takeIf { it.isNotBlank() }
}
private fun localTorrentResolveCredential(
settings: DebridSettings = DebridSettingsRepository.snapshot(),
): DebridServiceCredential? =
DebridProviders.configuredServices(settings)
.firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) }
private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): String? {
val resolve = clientResolve
if (resolve == null && needsLocalDebridResolve) {
val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim().takeIf { it.isNotBlank() } ?: return null
val account = localTorrentResolveCredential() ?: return null
val apiKey = account.apiKey.trim().takeIf { it.isNotBlank() } ?: return null
val identity = infoHash ?: torrentMagnetUri ?: behaviorHints.filename ?: return null
return listOf(
DebridProviders.TORBOX_ID,
account.provider.id,
apiKey.stableFingerprint(),
identity.trim().lowercase(),
fileIdx?.toString().orEmpty(),
@ -442,11 +294,11 @@ private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): Strin
}
resolve ?: return null
val providerId = DebridProviders.byId(resolve.service)?.id ?: return null
val apiKey = when (providerId) {
DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey
DebridProviders.REAL_DEBRID_ID -> DebridSettingsRepository.snapshot().realDebridApiKey
else -> ""
}.trim().takeIf { it.isNotBlank() } ?: return null
val apiKey = DebridSettingsRepository.snapshot()
.apiKeyFor(providerId)
.trim()
.takeIf { it.isNotBlank() }
?: return null
val identity = resolve.infoHash
?: resolve.magnetUri
?: resolve.torrentName
@ -464,7 +316,7 @@ private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): Strin
).joinToString("|")
}
private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?): StreamClientResolve =
private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?, providerId: String): StreamClientResolve =
StreamClientResolve(
type = "torrent",
infoHash = infoHash,
@ -475,7 +327,7 @@ private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?): StreamCli
filename = behaviorHints.filename,
season = season,
episode = episode,
service = DebridProviders.TORBOX_ID,
service = providerId,
isCached = debridCacheStatus?.state == StreamDebridCacheState.CACHED,
)

View file

@ -42,7 +42,7 @@ object DirectDebridStreamPreparer {
}
if (!consumeBackgroundBudget()) {
log.d { "Skipping instant playback preparation; local Torbox budget reached" }
log.d { "Skipping instant playback preparation; local debrid budget reached" }
return
}

View file

@ -4,23 +4,21 @@ import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamDebridCacheState
import com.nuvio.app.features.streams.StreamDebridCacheStatus
import com.nuvio.app.features.streams.StreamItem
import kotlinx.coroutines.CancellationException
object TorboxAvailabilityService {
object LocalDebridAvailabilityService {
fun markChecking(
groups: List<AddonStreamGroup>,
eligibleGroupIds: Set<String>? = null,
): List<AddonStreamGroup> {
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled || settings.torboxApiKey.isBlank()) return groups
val account = cacheCheckAccount() ?: return groups
return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
if (stream.torboxAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) {
if (stream.localAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) {
stream
} else {
stream.copy(
debridCacheStatus = StreamDebridCacheStatus(
providerId = DebridProviders.TORBOX_ID,
providerName = DebridProviders.Torbox.displayName,
providerId = account.provider.id,
providerName = account.provider.displayName,
state = StreamDebridCacheState.CHECKING,
),
)
@ -32,31 +30,28 @@ object TorboxAvailabilityService {
groups: List<AddonStreamGroup>,
eligibleGroupIds: Set<String>? = null,
): List<AddonStreamGroup> {
val settings = DebridSettingsRepository.snapshot()
val apiKey = settings.torboxApiKey.trim()
if (!settings.enabled || apiKey.isBlank()) return groups
val account = cacheCheckAccount() ?: return groups
val hashes = groups
.filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds }
.flatMap { group ->
group.streams.mapNotNull { stream ->
stream.torboxAvailabilityHash()
stream.localAvailabilityHash()
?.takeUnless { stream.debridCacheStatus?.state in FINAL_CACHE_STATES }
}
}
.distinct()
if (hashes.isEmpty()) return groups
val cached = checkCached(apiKey = apiKey, hashes = hashes)
val cached = LocalDebridService.checkCached(account = account, hashes = hashes)
?: return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
val hash = stream.torboxAvailabilityHash()
val hash = stream.localAvailabilityHash()
if (hash == null) {
stream
} else {
stream.copy(
debridCacheStatus = StreamDebridCacheStatus(
providerId = DebridProviders.TORBOX_ID,
providerName = DebridProviders.Torbox.displayName,
providerId = account.provider.id,
providerName = account.provider.displayName,
state = StreamDebridCacheState.UNKNOWN,
),
)
@ -64,13 +59,13 @@ object TorboxAvailabilityService {
}
return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
val hash = stream.torboxAvailabilityHash() ?: return@updateAvailabilityStatus stream
val hash = stream.localAvailabilityHash() ?: return@updateAvailabilityStatus stream
if (stream.debridCacheStatus?.state in FINAL_CACHE_STATES) return@updateAvailabilityStatus stream
val cachedItem = cached[hash]
stream.copy(
debridCacheStatus = StreamDebridCacheStatus(
providerId = DebridProviders.TORBOX_ID,
providerName = DebridProviders.Torbox.displayName,
providerId = account.provider.id,
providerName = account.provider.displayName,
state = if (cachedItem == null) StreamDebridCacheState.NOT_CACHED else StreamDebridCacheState.CACHED,
cachedName = cachedItem?.name,
cachedSize = cachedItem?.size,
@ -80,28 +75,16 @@ object TorboxAvailabilityService {
}
suspend fun isCached(hash: String): Boolean? {
val settings = DebridSettingsRepository.snapshot()
val apiKey = settings.torboxApiKey.trim()
val normalizedHash = hash.trim().lowercase().takeIf { it.isNotBlank() } ?: return null
if (!settings.enabled || apiKey.isBlank()) return null
return checkCached(apiKey = apiKey, hashes = listOf(normalizedHash))?.containsKey(normalizedHash)
val account = cacheCheckAccount() ?: return null
return LocalDebridService.isCached(account, hash)
}
private suspend fun checkCached(
apiKey: String,
hashes: List<String>,
): Map<String, TorboxCachedItemDto>? =
try {
val response = TorboxApiClient.checkCached(apiKey = apiKey, hashes = hashes)
if (!response.isSuccessful || response.body?.success == false) {
null
} else {
response.body?.data.orEmpty().mapKeys { it.key.lowercase() }
}
} catch (error: Exception) {
if (error is CancellationException) throw error
null
}
private fun cacheCheckAccount(): DebridServiceCredential? {
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled) return null
return DebridProviders.configuredServices(settings)
.firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) }
}
}
private val FINAL_CACHE_STATES = setOf(
@ -109,7 +92,7 @@ private val FINAL_CACHE_STATES = setOf(
StreamDebridCacheState.NOT_CACHED,
)
internal fun StreamItem.torboxAvailabilityHash(): String? =
internal fun StreamItem.localAvailabilityHash(): String? =
infoHash
?.trim()
?.lowercase()

View file

@ -0,0 +1,45 @@
package com.nuvio.app.features.debrid
import kotlinx.coroutines.CancellationException
internal data class LocalDebridCachedItem(
val name: String?,
val size: Long?,
)
internal object LocalDebridService {
suspend fun checkCached(
account: DebridServiceCredential,
hashes: List<String>,
): Map<String, LocalDebridCachedItem>? =
when (account.provider.id) {
DebridProviders.TORBOX_ID -> checkTorboxCached(account.apiKey, hashes)
else -> null
}
suspend fun isCached(account: DebridServiceCredential, hash: String): Boolean? {
val normalizedHash = hash.trim().lowercase().takeIf { it.isNotBlank() } ?: return null
return checkCached(account, listOf(normalizedHash))?.containsKey(normalizedHash)
}
private suspend fun checkTorboxCached(
apiKey: String,
hashes: List<String>,
): Map<String, LocalDebridCachedItem>? =
try {
val response = TorboxApiClient.checkCached(apiKey = apiKey, hashes = hashes)
if (!response.isSuccessful || response.body?.success == false) {
null
} else {
response.body?.data.orEmpty().mapKeys { it.key.lowercase() }.mapValues { (_, value) ->
LocalDebridCachedItem(
name = value.name,
size = value.size,
)
}
}
} catch (error: Exception) {
if (error is CancellationException) throw error
null
}
}

View file

@ -8,7 +8,7 @@ import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.debrid.DebridStreamPresentation
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.TorboxAvailabilityService
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.pluginContentId
@ -274,14 +274,14 @@ object PlayerStreamsRepository {
if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return
val eligibleGroupIds = setOf(group.addonId)
val checkingGroup = TorboxAvailabilityService.markChecking(
val checkingGroup = LocalDebridAvailabilityService.markChecking(
groups = listOf(group),
eligibleGroupIds = eligibleGroupIds,
).firstOrNull() ?: group
publishStreamGroup(checkingGroup)
val availabilityJob = launch {
val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability(
val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability(
groups = listOf(checkingGroup),
eligibleGroupIds = eligibleGroupIds,
).firstOrNull() ?: checkingGroup

View file

@ -85,7 +85,7 @@ 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_not_set
import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description
import nuvio.composeapp.generated.resources.settings_debrid_provider_description
import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback
import nuvio.composeapp.generated.resources.settings_debrid_section_formatting
import nuvio.composeapp.generated.resources.settings_debrid_section_providers
@ -127,35 +127,43 @@ internal fun LazyListScope.debridSettingsContent(
}
item {
var showApiKeyDialog by rememberSaveable { mutableStateOf(false) }
var activeProviderId by rememberSaveable { mutableStateOf<String?>(null) }
val providers = remember { DebridProviders.visible() }
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_providers),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
DebridPreferenceRow(
isTablet = isTablet,
title = DebridProviders.Torbox.displayName,
description = stringResource(Res.string.settings_debrid_provider_torbox_description),
value = maskDebridApiKey(settings.torboxApiKey, stringResource(Res.string.settings_debrid_not_set)),
enabled = true,
onClick = { showApiKeyDialog = true },
)
providers.forEachIndexed { index, provider ->
if (index > 0) {
SettingsGroupDivider(isTablet = isTablet)
}
DebridPreferenceRow(
isTablet = isTablet,
title = provider.displayName,
description = stringResource(Res.string.settings_debrid_provider_description, provider.displayName),
value = maskDebridApiKey(settings.apiKeyFor(provider.id), stringResource(Res.string.settings_debrid_not_set)),
enabled = true,
onClick = { activeProviderId = provider.id },
)
}
}
}
if (showApiKeyDialog) {
DebridApiKeyDialog(
providerId = DebridProviders.TORBOX_ID,
title = stringResource(Res.string.settings_debrid_dialog_title),
subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle),
placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder),
currentValue = settings.torboxApiKey,
onSave = DebridSettingsRepository::setTorboxApiKey,
onDismiss = { showApiKeyDialog = false },
)
}
activeProviderId
?.let(DebridProviders::byId)
?.let { provider ->
DebridApiKeyDialog(
providerId = provider.id,
title = stringResource(Res.string.settings_debrid_dialog_title, provider.displayName),
subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle, provider.displayName),
placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder, provider.displayName),
currentValue = settings.apiKeyFor(provider.id),
onSave = { apiKey -> DebridSettingsRepository.setProviderApiKey(provider.id, apiKey) },
onDismiss = { activeProviderId = null },
)
}
}
item {

View file

@ -10,7 +10,7 @@ import com.nuvio.app.features.debrid.DebridSettings
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.debrid.DebridStreamPresentation
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.TorboxAvailabilityService
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
import com.nuvio.app.features.player.PlayerSettingsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -120,11 +120,11 @@ object AddonStreamWarmupRepository {
videoId = key.videoId,
)
val eligibleGroupIds = setOf(group.addonId)
val checkingGroup = TorboxAvailabilityService.markChecking(
val checkingGroup = LocalDebridAvailabilityService.markChecking(
groups = listOf(group),
eligibleGroupIds = eligibleGroupIds,
).firstOrNull() ?: group
val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability(
val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability(
groups = listOf(checkingGroup),
eligibleGroupIds = eligibleGroupIds,
).firstOrNull() ?: checkingGroup

View file

@ -8,7 +8,7 @@ import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.debrid.DebridStreamPresentation
import com.nuvio.app.features.debrid.TorboxAvailabilityService
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
@ -265,14 +265,14 @@ object StreamsRepository {
if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return
val eligibleGroupIds = setOf(group.addonId)
val checkingGroup = TorboxAvailabilityService.markChecking(
val checkingGroup = LocalDebridAvailabilityService.markChecking(
groups = listOf(group),
eligibleGroupIds = eligibleGroupIds,
).firstOrNull() ?: group
publishAddonGroup(checkingGroup)
val availabilityJob = launch {
val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability(
val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability(
groups = listOf(checkingGroup),
eligibleGroupIds = eligibleGroupIds,
).firstOrNull() ?: checkingGroup

View file

@ -0,0 +1,22 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DebridProviderTest {
@Test
fun `torbox exposes local addon capabilities`() {
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve))
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck))
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve))
}
@Test
fun `real debrid stays hidden from local addon capability paths`() {
assertFalse(DebridProviders.RealDebrid.visibleInUi)
assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve))
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck))
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentResolve))
}
}

View file

@ -0,0 +1,36 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DebridSettingsTest {
@Test
fun `normalizes provider ids when reading api keys`() {
val settings = DebridSettings(
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "tb_key"),
)
assertEquals("tb_key", settings.apiKeyFor("TORBOX"))
assertEquals("tb_key", settings.torboxApiKey)
assertEquals("", settings.realDebridApiKey)
}
@Test
fun `configured services are driven by visible registered providers`() {
val settings = DebridSettings(
providerApiKeys = mapOf(
DebridProviders.TORBOX_ID to "tb_key",
DebridProviders.REAL_DEBRID_ID to "rd_key",
),
)
val services = DebridProviders.configuredServices(settings)
assertEquals(listOf(DebridProviders.TORBOX_ID), services.map { it.provider.id })
assertEquals("tb_key", services.single().apiKey)
assertTrue(settings.hasAnyApiKey)
assertFalse(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID))
}
}

View file

@ -21,7 +21,7 @@ class DebridStreamPresentationTest {
stream = stream,
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
streamNameTemplate = "{stream.resolution} {service.shortName} {service.cached::istrue[\"Ready\"||\"Not Ready\"]}",
streamDescriptionTemplate = "{stream.quality} {stream.encode}\n{stream.size::bytes}\n{stream.filename}",
),
@ -67,7 +67,7 @@ class DebridStreamPresentationTest {
groups = listOf(group),
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
streamMaxResults = 2,
streamSortMode = DebridStreamSortMode.QUALITY_DESC,
streamMinimumQuality = DebridStreamMinimumQuality.P1080,
@ -102,7 +102,7 @@ class DebridStreamPresentationTest {
),
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
),
).single().streams

View file

@ -56,8 +56,8 @@ class DirectDebridStreamPreparerTest {
StreamItem(
name = name,
url = url,
addonName = "Torbox Instant",
addonId = "debrid:torbox",
addonName = "Addon",
addonId = "addon:test",
clientResolve = StreamClientResolve(
type = "debrid",
service = DebridProviders.TORBOX_ID,

View file

@ -26,21 +26,20 @@ actual object DebridSettingsStorage {
private const val streamPreferencesKey = "debrid_stream_preferences"
private const val streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
streamMinimumQualityKey,
streamDolbyVisionFilterKey,
streamHdrFilterKey,
streamCodecFilterKey,
streamPreferencesKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
private fun syncKeys(): List<String> =
listOf(
enabledKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
streamMinimumQualityKey,
streamDolbyVisionFilterKey,
streamHdrFilterKey,
streamCodecFilterKey,
streamPreferencesKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
) + DebridProviders.all().map { providerApiKeyKey(it.id) }
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
@ -48,16 +47,23 @@ actual object DebridSettingsStorage {
saveBoolean(enabledKey, enabled)
}
actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey)
actual fun loadProviderApiKey(providerId: String): String? =
loadString(providerApiKeyKey(providerId))
actual fun saveTorboxApiKey(apiKey: String) {
saveString(torboxApiKeyKey, apiKey)
actual fun saveProviderApiKey(providerId: String, apiKey: String) {
saveString(providerApiKeyKey(providerId), apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey)
actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID)
actual fun saveTorboxApiKey(apiKey: String) {
saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID)
actual fun saveRealDebridApiKey(apiKey: String) {
saveString(realDebridApiKeyKey, apiKey)
saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey)
}
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
@ -157,8 +163,11 @@ actual object DebridSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
DebridProviders.all().forEach { provider ->
loadProviderApiKey(provider.id)?.let {
put(providerApiKeyKey(provider.id), encodeSyncString(it))
}
}
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
@ -172,13 +181,16 @@ actual object DebridSettingsStorage {
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
syncKeys.forEach { key ->
syncKeys().forEach { key ->
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key))
}
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
DebridProviders.all().forEach { provider ->
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey ->
saveProviderApiKey(provider.id, apiKey)
}
}
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
@ -190,4 +202,14 @@ actual object DebridSettingsStorage {
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
private fun providerApiKeyKey(providerId: String): String {
val normalized = DebridProviders.byId(providerId)?.id
?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_")
return when (normalized) {
DebridProviders.TORBOX_ID -> torboxApiKeyKey
DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey
else -> "debrid_${normalized}_api_key"
}
}
}