ref(torbox): short_name extraction

This commit is contained in:
tapframe 2026-05-21 14:01:03 +05:30
parent b0906b7b19
commit cf1e162eb1
25 changed files with 348 additions and 72 deletions

View file

@ -16,6 +16,7 @@ import kotlinx.serialization.json.put
actual object DebridSettingsStorage {
private const val preferencesName = "nuvio_debrid_settings"
private const val enabledKey = "debrid_enabled"
private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled"
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
@ -31,6 +32,7 @@ actual object DebridSettingsStorage {
private fun syncKeys(): List<String> =
listOf(
enabledKey,
cloudLibraryEnabledKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
@ -55,6 +57,12 @@ actual object DebridSettingsStorage {
saveBoolean(enabledKey, enabled)
}
actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey)
actual fun saveCloudLibraryEnabled(enabled: Boolean) {
saveBoolean(cloudLibraryEnabledKey, enabled)
}
actual fun loadProviderApiKey(providerId: String): String? =
loadString(providerApiKeyKey(providerId))
@ -180,6 +188,7 @@ actual object DebridSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) }
DebridProviders.all().forEach { provider ->
loadProviderApiKey(provider.id)?.let {
put(providerApiKeyKey(provider.id), encodeSyncString(it))
@ -203,6 +212,7 @@ actual object DebridSettingsStorage {
}?.apply()
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled)
DebridProviders.all().forEach { provider ->
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey ->
saveProviderApiKey(provider.id, apiKey)

View file

@ -591,8 +591,10 @@
<string name="settings_integrations_debrid_description">Administrer skytjenestekontoer og tilgang til skybibliotek</string>
<string name="settings_debrid_section_title">Skytjenester</string>
<string name="settings_debrid_experimental_notice">Støtte for skytjenester er eksperimentell og kan endres eller fjernes senere.</string>
<string name="settings_debrid_enable">Aktiver skytjenester</string>
<string name="settings_debrid_enable_description">Bruk tilkoblede kontoer for spillbare lenker og tilgang til skybibliotek.</string>
<string name="settings_debrid_cloud_library">Skybibliotek</string>
<string name="settings_debrid_cloud_library_description">Bla gjennom og spill filer som allerede finnes i tilkoblede skytjenester.</string>
<string name="settings_debrid_enable">Løs spillbare lenker</string>
<string name="settings_debrid_enable_description">Be en tilkoblet tjeneste om spillbare lenker når et resultat trenger det. Dette kan legge elementet til i den tjenesten.</string>
<string name="settings_debrid_add_key_first">Koble til en skytjenestekonto først.</string>
<string name="settings_debrid_section_providers">Skytjenester</string>
<string name="settings_debrid_provider_description">Koble til %1$s-kontoen din.</string>
@ -612,7 +614,7 @@
<string name="settings_debrid_device_auth_waiting">Venter på godkjenning...</string>
<string name="settings_debrid_device_auth_failed">Kunne ikke starte innlogging.</string>
<string name="settings_debrid_device_auth_expired">Denne koden er utløpt. Prøv igjen.</string>
<string name="settings_debrid_section_instant_playback">Umiddelbar avspilling</string>
<string name="settings_debrid_section_instant_playback">Lenkeforberedelse</string>
<string name="settings_debrid_prepare_instant_playback">Forbered lenker</string>
<string name="settings_debrid_prepare_instant_playback_description">Løs spillbare lenker før avspilling starter.</string>
<string name="settings_debrid_prepare_stream_count">Lenker å forberede</string>

View file

@ -592,8 +592,10 @@
<string name="settings_integrations_debrid_description">Manage cloud service accounts and cloud library access</string>
<string name="settings_debrid_section_title">Cloud Services</string>
<string name="settings_debrid_experimental_notice">Cloud Services support is experimental and may be kept, changed, or removed later.</string>
<string name="settings_debrid_enable">Enable cloud services</string>
<string name="settings_debrid_enable_description">Use connected accounts for playable links and cloud library access.</string>
<string name="settings_debrid_cloud_library">Cloud library</string>
<string name="settings_debrid_cloud_library_description">Browse and play files already in your connected cloud services.</string>
<string name="settings_debrid_enable">Resolve playable links</string>
<string name="settings_debrid_enable_description">Ask a connected service for playable links when a result needs it. This may add the item to that service.</string>
<string name="settings_debrid_add_key_first">Connect a cloud service account first.</string>
<string name="settings_debrid_section_providers">Cloud Services</string>
<string name="settings_debrid_provider_description">Connect your %1$s account.</string>
@ -614,7 +616,7 @@
<string name="settings_debrid_device_auth_waiting">Waiting for approval...</string>
<string name="settings_debrid_device_auth_failed">Could not start sign-in.</string>
<string name="settings_debrid_device_auth_expired">This code expired. Try again.</string>
<string name="settings_debrid_section_instant_playback">Instant Playback</string>
<string name="settings_debrid_section_instant_playback">Link Preparation</string>
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
<string name="settings_debrid_prepare_instant_playback_description">Resolve playable links before playback starts.</string>
<string name="settings_debrid_prepare_stream_count">Links to prepare</string>
@ -1330,6 +1332,9 @@
<string name="cloud_library_connect_action">Connect account</string>
<string name="cloud_library_connect_message">Connect Torbox in Cloud Services settings to browse playable files from your cloud library.</string>
<string name="cloud_library_connect_title">No cloud account connected</string>
<string name="cloud_library_disabled_action">Open Cloud Services</string>
<string name="cloud_library_disabled_message">Turn on Cloud library in Cloud Services settings to browse files from connected accounts.</string>
<string name="cloud_library_disabled_title">Cloud library is off</string>
<string name="cloud_library_empty_message">No playable cloud files match the current filters.</string>
<string name="cloud_library_empty_title">Nothing here yet</string>
<string name="cloud_library_file_picker_title">Choose a file to play</string>

View file

@ -52,6 +52,7 @@ data class CloudLibraryProviderState(
data class CloudLibraryUiState(
val isLoaded: Boolean = false,
val isEnabled: Boolean = true,
val isRefreshing: Boolean = false,
val providers: List<CloudLibraryProviderState> = emptyList(),
) {

View file

@ -87,6 +87,11 @@ object CloudLibraryRepository {
fun ensureLoaded() {
DebridSettingsRepository.ensureLoaded()
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
loadedConnectionKeys = emptyList()
_uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false)
return
}
val current = _uiState.value
if (current.isRefreshing) return
val connectedKeys = connectedCloudConnectionKeys()
@ -96,8 +101,15 @@ object CloudLibraryRepository {
}
fun refresh() {
DebridSettingsRepository.ensureLoaded()
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
loadedConnectionKeys = emptyList()
_uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false)
return
}
_uiState.update { current ->
current.copy(
isEnabled = true,
isRefreshing = true,
providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) },
)
@ -112,11 +124,19 @@ object CloudLibraryRepository {
suspend fun resolvePlayback(
item: CloudLibraryItem,
file: CloudLibraryFile,
): CloudLibraryPlaybackResult =
store.resolvePlayback(item, file)
): CloudLibraryPlaybackResult {
DebridSettingsRepository.ensureLoaded()
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
return CloudLibraryPlaybackResult.Failed("Cloud library is disabled.")
}
return store.resolvePlayback(item, file)
}
private fun connectedCloudCredentials(): List<DebridServiceCredential> =
DebridProviders.configuredServices(DebridSettingsRepository.snapshot())
DebridSettingsRepository.snapshot()
.takeIf { settings -> settings.cloudLibraryEnabled }
?.let(DebridProviders::configuredServices)
.orEmpty()
.filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
private fun connectedCloudConnectionKeys(): List<CloudConnectionKey> =

View file

@ -83,8 +83,9 @@ internal fun TorboxCloudItemDto.toCloudLibraryItem(
val itemId = id.scalarString()
?: hash?.trim()?.takeIf { it.isNotBlank() }
?: return null
val itemName = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId
val mappedFiles = files.orEmpty().mapNotNull { file ->
file.toCloudLibraryFile()
file.toCloudLibraryFile(parentName = itemName)
}
val filesSize = mappedFiles
.mapNotNull { it.sizeBytes }
@ -95,7 +96,7 @@ internal fun TorboxCloudItemDto.toCloudLibraryItem(
providerName = providerName,
id = itemId,
type = type,
name = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId,
name = itemName,
status = listOf(status, downloadState, state)
.firstNonBlank(),
sizeBytes = size ?: totalSize ?: filesSize,
@ -104,9 +105,8 @@ internal fun TorboxCloudItemDto.toCloudLibraryItem(
)
}
internal fun TorboxCloudFileDto.toCloudLibraryFile(): CloudLibraryFile? {
val name = listOf(name, shortName, absolutePath)
.firstNonBlank()
internal fun TorboxCloudFileDto.toCloudLibraryFile(parentName: String? = null): CloudLibraryFile? {
val name = bestCloudFileName(parentName = parentName)
?: return null
val fileId = id.scalarString()
val mime = listOf(mimeType, mimeTypeAlt).firstNonBlank()
@ -119,6 +119,32 @@ internal fun TorboxCloudFileDto.toCloudLibraryFile(): CloudLibraryFile? {
)
}
private fun TorboxCloudFileDto.bestCloudFileName(parentName: String?): String? {
val rawName = name?.trim()?.takeIf { it.isNotBlank() }
val short = shortName?.trim()?.takeIf { it.isNotBlank() }
val pathName = absolutePath
?.trim()
?.pathBasename()
?.takeIf { it.isNotBlank() }
val parent = parentName?.trim()?.takeIf { it.isNotBlank() }
val rawNameIsPath = rawName?.isPathLike() == true
val rawNameBasename = rawName
?.takeIf { rawNameIsPath }
?.pathBasename()
?.takeIf { it.isNotBlank() }
val candidates = listOf(
short,
rawNameBasename,
rawName?.takeUnless { rawNameIsPath },
pathName,
rawName,
absolutePath?.trim()?.takeIf { it.isNotBlank() },
)
return candidates.firstOrNull { candidate ->
candidate?.isUsableCloudFileName(parentName = parent, pathName = pathName) == true
} ?: candidates.firstNonBlank()
}
internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String =
when (type) {
CloudLibraryItemType.Torrent -> "torrent_id"
@ -129,6 +155,33 @@ internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String =
private fun List<String?>.firstNonBlank(): String? =
firstOrNull { !it.isNullOrBlank() }?.trim()
private fun String.sameDisplayName(other: String?): Boolean {
val normalized = normalizeDisplayName()
return normalized.isNotBlank() && normalized == other?.normalizeDisplayName()
}
private fun String.isUsableCloudFileName(parentName: String?, pathName: String?): Boolean {
if (isBlank() || sameDisplayName(parentName)) return false
val pathNameWithoutExtension = pathName?.substringBeforeLast('.', pathName)
if (!contains('.') && sameDisplayName(pathNameWithoutExtension)) return false
return true
}
private fun String.isPathLike(): Boolean =
contains('/') || contains('\\')
private fun String.pathBasename(): String =
substringAfterLast('/').substringAfterLast('\\')
private fun String.normalizeDisplayName(): String =
trim()
.substringAfterLast('/')
.substringAfterLast('\\')
.substringBeforeLast('.', this)
.lowercase()
.replace(Regex("[^a-z0-9]+"), " ")
.trim()
private fun kotlinx.serialization.json.JsonElement?.scalarString(): String? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.content.trim().takeIf { it.isNotBlank() }

View file

@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
data class DebridSettings(
val enabled: Boolean = false,
val cloudLibraryEnabled: Boolean = true,
val providerApiKeys: Map<String, String> = emptyMap(),
val instantPlaybackPreparationLimit: Int = 0,
val streamMaxResults: Int = 0,
@ -25,6 +26,19 @@ data class DebridSettings(
val hasAnyApiKey: Boolean
get() = DebridProviders.configuredServices(this).isNotEmpty()
val linkResolvingEnabled: Boolean
get() = enabled
val canResolvePlayableLinks: Boolean
get() = linkResolvingEnabled && hasAnyApiKey
val hasCloudLibraryProvider: Boolean
get() = DebridProviders.configuredServices(this)
.any { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
val canUseCloudLibrary: Boolean
get() = cloudLibraryEnabled && hasCloudLibraryProvider
val hasCustomStreamFormatting: Boolean
get() = streamNameTemplate.isNotBlank() || streamDescriptionTemplate.isNotBlank()

View file

@ -21,6 +21,7 @@ object DebridSettingsRepository {
private var hasLoaded = false
private var enabled = false
private var cloudLibraryEnabled = true
private var providerApiKeys = emptyMap<String, String>()
private var instantPlaybackPreparationLimit = 0
private var streamMaxResults = 0
@ -56,6 +57,19 @@ object DebridSettingsRepository {
DebridSettingsStorage.saveEnabled(value)
}
fun setLinkResolvingEnabled(value: Boolean) {
setEnabled(value)
}
fun setCloudLibraryEnabled(value: Boolean) {
ensureLoaded()
if (value && !hasVisibleApiKey()) return
if (cloudLibraryEnabled == value) return
cloudLibraryEnabled = value
publish()
DebridSettingsStorage.saveCloudLibraryEnabled(value)
}
fun setProviderApiKey(providerId: String, value: String) {
ensureLoaded()
val provider = DebridProviders.byId(providerId) ?: return
@ -217,6 +231,7 @@ object DebridSettingsRepository {
}
.toMap()
enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey()
cloudLibraryEnabled = DebridSettingsStorage.loadCloudLibraryEnabled() ?: true
instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
)
@ -264,6 +279,7 @@ object DebridSettingsRepository {
private fun publish() {
_uiState.value = DebridSettings(
enabled = enabled,
cloudLibraryEnabled = cloudLibraryEnabled,
providerApiKeys = providerApiKeys,
instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
streamMaxResults = streamMaxResults,

View file

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

View file

@ -8,7 +8,7 @@ object DebridStreamPresentation {
private val formatter = DebridStreamFormatter()
fun apply(groups: List<AddonStreamGroup>, settings: DebridSettings): List<AddonStreamGroup> {
if (!settings.enabled) return groups
if (!settings.canResolvePlayableLinks) return groups
return groups.map { group ->
val visibleStreams = group.streams.filterNot { stream -> stream.isUncachedDebridStream }
val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream }

View file

@ -111,7 +111,7 @@ object DirectDebridPlaybackResolver {
fun shouldResolveToPlayableStream(stream: StreamItem): Boolean {
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled) return false
if (!settings.canResolvePlayableLinks) return false
if (stream.needsLocalDebridResolve) {
return stream.isInstalledAddonStream && localTorrentResolveCredential(settings) != null
}

View file

@ -27,7 +27,7 @@ object DirectDebridStreamPreparer {
) {
val settings = DebridSettingsRepository.snapshot()
val limit = settings.instantPlaybackPreparationLimit
if (!settings.enabled || limit <= 0 || !settings.hasAnyApiKey) return
if (!settings.canResolvePlayableLinks || limit <= 0) return
val candidates = prioritizeCandidates(
streams = streams,

View file

@ -81,7 +81,7 @@ object LocalDebridAvailabilityService {
private fun cacheCheckAccount(): DebridServiceCredential? {
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled) return null
if (!settings.canResolvePlayableLinks) return null
return DebridProviders.configuredServices(settings)
.firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) }
}

View file

@ -68,6 +68,7 @@ import com.nuvio.app.features.cloud.CloudLibraryItem
import com.nuvio.app.features.cloud.CloudLibraryItemType
import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.cloud.CloudLibraryUiState
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomePosterCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
@ -95,6 +96,10 @@ fun LibraryScreen(
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val cloudUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
val cloudSettings by remember {
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val watchedUiState by remember {
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
@ -151,7 +156,7 @@ fun LibraryScreen(
}
}
LaunchedEffect(sourceMode) {
LaunchedEffect(sourceMode, cloudSettings.cloudLibraryEnabled, cloudSettings.providerApiKeys) {
if (sourceMode == LibraryViewMode.Cloud) {
CloudLibraryRepository.ensureLoaded()
selectedCloudItemKey = null
@ -307,6 +312,18 @@ private fun LazyListScope.cloudLibraryContent(
cloudLibrarySkeletonItems()
}
!uiState.isEnabled -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = stringResource(Res.string.cloud_library_disabled_title),
message = stringResource(Res.string.cloud_library_disabled_message),
actionLabel = stringResource(Res.string.cloud_library_disabled_action),
onActionClick = onConnectCloudClick,
)
}
}
!uiState.hasConnectedProvider -> {
item {
HomeEmptyStateCard(
@ -702,46 +719,59 @@ private fun CloudLibraryFileRow(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.58f))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
.clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.58f),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
file.sizeBytes?.let { size ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
modifier = Modifier
.padding(top = 2.dp)
.size(18.dp),
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = formatCloudBytes(size),
modifier = Modifier.weight(1f),
text = file.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = file.sizeBytes?.let { size -> formatCloudBytes(size) }.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = stringResource(Res.string.cloud_library_play_file),
tint = MaterialTheme.colorScheme.primary,
)
}
}
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = stringResource(Res.string.cloud_library_play_file),
tint = MaterialTheme.colorScheme.primary,
)
}
}

View file

@ -608,7 +608,7 @@ private fun EpisodeStreamsSubView(
) { _, stream ->
EpisodeSourceStreamRow(
stream = stream,
enabled = stream.isSelectableForPlayback(debridSettings.enabled),
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
onClick = { onStreamSelected(stream, episode) },
)
}

View file

@ -1166,7 +1166,7 @@ fun PlayerScreen(
null
},
preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup,
debridEnabled = DebridSettingsRepository.snapshot().enabled,
debridEnabled = DebridSettingsRepository.snapshot().canResolvePlayableLinks,
)
} else null

View file

@ -221,7 +221,7 @@ fun PlayerSourcesPanel(
SourceStreamRow(
stream = stream,
isCurrent = isCurrent,
enabled = stream.isSelectableForPlayback(debridSettings.enabled),
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
onClick = { onStreamSelected(stream) },
)
}

View file

@ -77,6 +77,8 @@ import nuvio.composeapp.generated.resources.action_reset
import nuvio.composeapp.generated.resources.action_save
import nuvio.composeapp.generated.resources.action_saving
import nuvio.composeapp.generated.resources.settings_debrid_add_key_first
import nuvio.composeapp.generated.resources.settings_debrid_cloud_library
import nuvio.composeapp.generated.resources.settings_debrid_cloud_library_description
import nuvio.composeapp.generated.resources.settings_debrid_connected
import nuvio.composeapp.generated.resources.settings_debrid_connect_provider
import nuvio.composeapp.generated.resources.settings_debrid_disconnect_provider
@ -132,13 +134,22 @@ internal fun LazyListScope.debridSettingsContent(
text = stringResource(Res.string.settings_debrid_experimental_notice),
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_debrid_cloud_library),
description = stringResource(Res.string.settings_debrid_cloud_library_description),
checked = settings.canUseCloudLibrary,
enabled = settings.hasCloudLibraryProvider,
isTablet = isTablet,
onCheckedChange = DebridSettingsRepository::setCloudLibraryEnabled,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_debrid_enable),
description = stringResource(Res.string.settings_debrid_enable_description),
checked = settings.enabled && settings.hasAnyApiKey,
checked = settings.canResolvePlayableLinks,
enabled = settings.hasAnyApiKey,
isTablet = isTablet,
onCheckedChange = DebridSettingsRepository::setEnabled,
onCheckedChange = DebridSettingsRepository::setLinkResolvingEnabled,
)
if (!settings.hasAnyApiKey) {
SettingsGroupDivider(isTablet = isTablet)
@ -220,10 +231,12 @@ internal fun LazyListScope.debridSettingsContent(
}
}
if (!settings.canResolvePlayableLinks) return
item {
var showPrepareCountDialog by rememberSaveable { mutableStateOf(false) }
val prepareLimit = settings.instantPlaybackPreparationLimit
val prepareEnabled = settings.enabled && prepareLimit > 0
val prepareEnabled = prepareLimit > 0
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_instant_playback),
@ -234,7 +247,7 @@ internal fun LazyListScope.debridSettingsContent(
title = stringResource(Res.string.settings_debrid_prepare_instant_playback),
description = stringResource(Res.string.settings_debrid_prepare_instant_playback_description),
checked = prepareEnabled,
enabled = settings.enabled && settings.hasAnyApiKey,
enabled = settings.canResolvePlayableLinks,
isTablet = isTablet,
onCheckedChange = { enabled ->
DebridSettingsRepository.setInstantPlaybackPreparationLimit(
@ -281,7 +294,7 @@ internal fun LazyListScope.debridSettingsContent(
title = "Max results",
description = "Limit how many cloud-service results appear.",
value = streamMaxResultsLabel(preferences.maxResults),
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS },
)
SettingsGroupDivider(isTablet = isTablet)
@ -290,7 +303,7 @@ internal fun LazyListScope.debridSettingsContent(
title = "Sort results",
description = "Choose how cloud-service results are ordered.",
value = sortProfileLabel(preferences.sortCriteria),
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE },
)
SettingsGroupDivider(isTablet = isTablet)
@ -299,7 +312,7 @@ internal fun LazyListScope.debridSettingsContent(
title = "Per resolution limit",
description = "Cap repeated 2160p, 1080p, 720p results after sorting.",
value = streamMaxResultsLabel(preferences.maxPerResolution),
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION },
)
SettingsGroupDivider(isTablet = isTablet)
@ -308,7 +321,7 @@ internal fun LazyListScope.debridSettingsContent(
title = "Per quality limit",
description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.",
value = streamMaxResultsLabel(preferences.maxPerQuality),
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY },
)
SettingsGroupDivider(isTablet = isTablet)
@ -317,7 +330,7 @@ internal fun LazyListScope.debridSettingsContent(
title = "Size range",
description = "Filter cloud-service results by file size.",
value = sizeRangeLabel(preferences),
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE },
)
rows.forEach { row ->
@ -327,7 +340,7 @@ internal fun LazyListScope.debridSettingsContent(
title = row.title,
description = row.description,
value = row.value,
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = row.picker },
)
}
@ -357,7 +370,7 @@ internal fun LazyListScope.debridSettingsContent(
title = stringResource(Res.string.settings_debrid_name_template),
description = stringResource(Res.string.settings_debrid_name_template_description),
value = templatePreview(settings.streamNameTemplate),
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = { activeTemplateField = DebridTemplateField.NAME },
)
SettingsGroupDivider(isTablet = isTablet)
@ -366,7 +379,7 @@ internal fun LazyListScope.debridSettingsContent(
title = stringResource(Res.string.settings_debrid_description_template),
description = stringResource(Res.string.settings_debrid_description_template_description),
value = templatePreview(settings.streamDescriptionTemplate),
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION },
)
SettingsGroupDivider(isTablet = isTablet)
@ -375,7 +388,7 @@ internal fun LazyListScope.debridSettingsContent(
title = stringResource(Res.string.settings_debrid_formatter_reset_title),
description = stringResource(Res.string.settings_debrid_formatter_reset_subtitle),
value = stringResource(Res.string.action_reset),
enabled = settings.enabled,
enabled = settings.canResolvePlayableLinks,
onClick = DebridSettingsRepository::resetStreamTemplates,
)
}

View file

@ -209,7 +209,7 @@ object AddonStreamWarmupRepository {
DebridSettingsRepository.ensureLoaded()
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled || settings.torboxApiKey.isBlank()) return null
if (!settings.canResolvePlayableLinks || settings.torboxApiKey.isBlank()) return null
AddonRepository.initialize()
val addonTargets = AddonRepository.uiState.value.addons

View file

@ -299,7 +299,7 @@ object StreamsRepository {
installedAddonNames = installedAddonNames,
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
debridEnabled = debridSettings.enabled,
debridEnabled = debridSettings.canResolvePlayableLinks,
)
_uiState.update { it.copy(autoPlayStream = selected) }
if (selected == null) {
@ -498,7 +498,7 @@ object StreamsRepository {
installedAddonNames = installedAddonNames,
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
debridEnabled = debridSettings.enabled,
debridEnabled = debridSettings.canResolvePlayableLinks,
)
_uiState.update { it.copy(autoPlayStream = selected) }
}

View file

@ -224,8 +224,8 @@ fun StreamsScreen(
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
uiState = uiState,
debridEnabled = debridSettings.enabled,
appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting,
debridEnabled = debridSettings.canResolvePlayableLinks,
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && !debridSettings.hasCustomStreamFormatting,
resumePositionMs = effectiveResumePositionMs,
resumeProgressFraction = effectiveResumeProgressFraction,
onStreamSelected = { stream, positionMs, progressFraction ->
@ -243,8 +243,8 @@ fun StreamsScreen(
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
uiState = uiState,
debridEnabled = debridSettings.enabled,
appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting,
debridEnabled = debridSettings.canResolvePlayableLinks,
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && !debridSettings.hasCustomStreamFormatting,
resumePositionMs = effectiveResumePositionMs,
resumeProgressFraction = effectiveResumeProgressFraction,
onStreamSelected = { stream, positionMs, progressFraction ->

View file

@ -64,10 +64,72 @@ class TorboxCloudLibraryProviderApiTest {
assertNotNull(item)
assertEquals("abc123", item.id)
assertEquals("abc123", item.name)
assertEquals("/downloads/show.mp4", item.files.single().name)
assertEquals("show.mp4", item.files.single().name)
assertTrue(item.files.single().playable)
}
@Test
fun `mapping prefers absolute path basename when file name repeats pack name`() {
val item = TorboxCloudItemDto(
id = JsonPrimitive(44),
name = "The Rookie S01",
files = listOf(
TorboxCloudFileDto(
id = JsonPrimitive(1),
name = "The Rookie S01",
absolutePath = "/The Rookie S01/The.Rookie.S01E01.1080p.WEB-DL.mkv",
mimeType = "video/x-matroska",
),
TorboxCloudFileDto(
id = JsonPrimitive(2),
shortName = "The Rookie S01",
absolutePath = "/The Rookie S01/The.Rookie.S01E02.1080p.WEB-DL.mkv",
mimeType = "video/x-matroska",
),
),
).toCloudLibraryItem(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.Torrent,
)
assertNotNull(item)
assertEquals(
listOf(
"The.Rookie.S01E01.1080p.WEB-DL.mkv",
"The.Rookie.S01E02.1080p.WEB-DL.mkv",
),
item.playableFiles.map { it.name },
)
}
@Test
fun `mapping prefers short name when Torbox file name is a relative pack path`() {
val item = TorboxCloudItemDto(
id = JsonPrimitive(29556645),
name = "From.The.Earth.To.The.Moon.1998.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]",
files = listOf(
TorboxCloudFileDto(
id = JsonPrimitive(1),
name = "From.The.Earth.To.The.Moon.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]/From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv",
shortName = "From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv",
absolutePath = "/completed/2c229180e129280a36ba7f3a22e2f5135a02a766/From.The.Earth.To.The.Moon.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]/From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv",
mimeType = "video/x-matroska",
),
),
).toCloudLibraryItem(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.Torrent,
)
assertNotNull(item)
assertEquals(
"From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv",
item.playableFiles.single().name,
)
}
@Test
fun `mapping handles missing item ids and empty file lists`() {
assertNull(

View file

@ -33,4 +33,16 @@ class DebridSettingsTest {
assertTrue(settings.hasAnyApiKey)
assertFalse(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID))
}
@Test
fun `cloud library and link resolving capabilities are independent`() {
val settings = DebridSettings(
enabled = false,
cloudLibraryEnabled = true,
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "tb_key"),
)
assertTrue(settings.canUseCloudLibrary)
assertFalse(settings.canResolvePlayableLinks)
}
}

View file

@ -109,6 +109,32 @@ class DebridStreamPresentationTest {
assertEquals(listOf("Cached"), presented.map { it.name })
}
@Test
fun `leaves cloud-service results untouched when link resolving is off`() {
val uncached = localTorboxStream(
name = "Uncached",
filename = "Movie.2160p.WEB-DL.HEVC-GRP.mkv",
size = 20_000_000_000,
cacheState = StreamDebridCacheState.NOT_CACHED,
)
val presented = DebridStreamPresentation.apply(
groups = listOf(
AddonStreamGroup(
addonName = "Addon",
addonId = "addon:test",
streams = listOf(uncached),
),
),
settings = DebridSettings(
enabled = false,
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
),
).single().streams
assertEquals(listOf("Uncached"), presented.map { it.name })
}
private fun localTorboxStream(
name: String = "Torrent",
filename: String,

View file

@ -14,6 +14,7 @@ import platform.Foundation.NSUserDefaults
actual object DebridSettingsStorage {
private const val enabledKey = "debrid_enabled"
private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled"
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
@ -29,6 +30,7 @@ actual object DebridSettingsStorage {
private fun syncKeys(): List<String> =
listOf(
enabledKey,
cloudLibraryEnabledKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
@ -47,6 +49,12 @@ actual object DebridSettingsStorage {
saveBoolean(enabledKey, enabled)
}
actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey)
actual fun saveCloudLibraryEnabled(enabled: Boolean) {
saveBoolean(cloudLibraryEnabledKey, enabled)
}
actual fun loadProviderApiKey(providerId: String): String? =
loadString(providerApiKeyKey(providerId))
@ -163,6 +171,7 @@ actual object DebridSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) }
DebridProviders.all().forEach { provider ->
loadProviderApiKey(provider.id)?.let {
put(providerApiKeyKey(provider.id), encodeSyncString(it))
@ -186,6 +195,7 @@ actual object DebridSettingsStorage {
}
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled)
DebridProviders.all().forEach { provider ->
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey ->
saveProviderApiKey(provider.id, apiKey)