From 8eb9f2c83baa9edfc5bd1db85b2b387d8bde713b Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Thu, 21 May 2026 12:56:53 +0530
Subject: [PATCH] feat: Add cloud library support
---
.../composeResources/values/strings.xml | 22 +
.../commonMain/kotlin/com/nuvio/app/App.kt | 57 ++
.../app/features/cloud/CloudLibraryModels.kt | 75 ++
.../features/cloud/CloudLibraryProviderApi.kt | 29 +
.../features/cloud/CloudLibraryRepository.kt | 134 +++
.../cloud/TorboxCloudLibraryProviderApi.kt | 169 ++++
.../app/features/debrid/DebridApiClients.kt | 81 ++
.../app/features/debrid/DebridApiModels.kt | 27 +
.../app/features/debrid/DebridProvider.kt | 2 +
.../app/features/library/LibraryScreen.kt | 849 ++++++++++++++++--
.../app/features/settings/SettingsScreen.kt | 11 +
.../features/cloud/CloudLibraryStoreTest.kt | 110 +++
.../TorboxCloudLibraryProviderApiTest.kt | 131 +++
.../app/features/debrid/DebridProviderTest.kt | 2 +
14 files changed, 1647 insertions(+), 52 deletions(-)
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 01a8c8a7..528d11ab 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -1320,11 +1320,33 @@
Your library is empty
Couldn't load library
Other
+ Cloud
+ Saved
Library
Connect Trakt and save titles to your watchlist or personal lists.
Your Trakt library is empty
Couldn't load Trakt library
Trakt Library
+ Connect account
+ Connect Torbox in Debrid settings to browse playable files from your cloud library.
+ No cloud account connected
+ No playable cloud files match the current filters.
+ Nothing here yet
+ Choose a file to play
+ Couldn't load %1$s cloud library
+ This item does not expose a playable video file.
+ No playable files
+ No playable files
+ Couldn't play this cloud file.
+ Play file
+ %1$d playable files
+ All
+ Refresh cloud library
+ Ready to play
+ All
+ Torrents
+ Usenet
+ Web
Anime
Channels
Movies
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 3d5e7c37..6cd778a3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -106,6 +106,10 @@ 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.cloud.CloudLibraryFile
+import com.nuvio.app.features.cloud.CloudLibraryItem
+import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult
+import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
@@ -565,6 +569,7 @@ private fun MainAppContent(
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterActionTarget by remember { mutableStateOf(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf(null) }
+ var requestedSettingsPageName by rememberSaveable { mutableStateOf(null) }
var showLibraryListPicker by remember { mutableStateOf(false) }
var pickerItem by remember { mutableStateOf(null) }
var pickerTitle by remember { mutableStateOf("") }
@@ -601,6 +606,7 @@ private fun MainAppContent(
val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured)
val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable)
val externalPlayerFailedText = stringResource(Res.string.external_player_failed)
+ val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed)
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
@@ -1157,6 +1163,45 @@ private fun MainAppContent(
)
},
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
+ onCloudFilePlay = { item, file ->
+ coroutineScope.launch {
+ when (
+ val resolved = CloudLibraryRepository.resolvePlayback(
+ item = item,
+ file = file,
+ )
+ ) {
+ is CloudLibraryPlaybackResult.Success -> {
+ val playbackTitle = file.name.ifBlank { item.name }
+ val playerLaunch = PlayerLaunch(
+ title = playbackTitle,
+ sourceUrl = resolved.url,
+ streamTitle = playbackTitle,
+ streamSubtitle = item.name.takeIf { it != playbackTitle },
+ providerName = item.providerName,
+ providerAddonId = "cloud:${item.providerId}",
+ contentType = "cloud",
+ videoId = "${item.stableKey}:${file.stableKey}",
+ parentMetaId = item.stableKey,
+ parentMetaType = "cloud",
+ )
+ if (playerSettingsUiState.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ return@launch
+ }
+ val launchId = PlayerLaunchStore.put(playerLaunch)
+ navController.navigate(PlayerRoute(launchId = launchId))
+ }
+ else -> {
+ NuvioToastController.show(cloudLibraryPlayFailedText)
+ }
+ }
+ }
+ },
+ onConnectCloudClick = {
+ requestedSettingsPageName = "Debrid"
+ selectedTab = AppScreenTab.Settings
+ },
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onSwitchProfile = onSwitchProfile,
@@ -1191,6 +1236,10 @@ private fun MainAppContent(
onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
},
+ requestedSettingsPageName = requestedSettingsPageName,
+ onRequestedSettingsPageConsumed = {
+ requestedSettingsPageName = null
+ },
onInitialHomeContentRendered = { initialHomeReady = true },
)
}
@@ -2282,6 +2331,8 @@ private fun AppTabHost(
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null,
+ onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
+ onConnectCloudClick: (() -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
onSwitchProfile: (() -> Unit)? = null,
@@ -2297,6 +2348,8 @@ private fun AppTabHost(
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
+ requestedSettingsPageName: String? = null,
+ onRequestedSettingsPageConsumed: () -> Unit = {},
onInitialHomeContentRendered: () -> Unit = {},
) {
val tabStateHolder = rememberSaveableStateHolder()
@@ -2336,6 +2389,8 @@ private fun AppTabHost(
onPosterClick = onLibraryPosterClick,
onPosterLongClick = onLibraryPosterLongClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
+ onCloudFilePlay = onCloudFilePlay,
+ onConnectCloudClick = onConnectCloudClick,
)
}
@@ -2343,6 +2398,8 @@ private fun AppTabHost(
SettingsScreen(
modifier = Modifier.fillMaxSize(),
rootActionRequests = settingsRootActionRequests,
+ requestedPageName = requestedSettingsPageName,
+ onRequestedPageConsumed = onRequestedSettingsPageConsumed,
rootActionsEnabled = rootActionsEnabled,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt
new file mode 100644
index 00000000..635192ee
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt
@@ -0,0 +1,75 @@
+package com.nuvio.app.features.cloud
+
+import com.nuvio.app.features.debrid.DebridProvider
+
+enum class CloudLibraryItemType {
+ Torrent,
+ Usenet,
+ WebDownload,
+}
+
+data class CloudLibraryFile(
+ val id: String?,
+ val name: String,
+ val sizeBytes: Long? = null,
+ val mimeType: String? = null,
+ val playable: Boolean = true,
+) {
+ val stableKey: String
+ get() = id ?: name
+}
+
+data class CloudLibraryItem(
+ val providerId: String,
+ val providerName: String,
+ val id: String,
+ val type: CloudLibraryItemType,
+ val name: String,
+ val status: String? = null,
+ val sizeBytes: Long? = null,
+ val progressFraction: Float? = null,
+ val files: List = emptyList(),
+) {
+ val stableKey: String
+ get() = "$providerId:${type.name}:$id"
+
+ val playableFiles: List
+ get() = files.filter { it.playable }
+}
+
+data class CloudLibraryProviderState(
+ val provider: DebridProvider,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val items: List = emptyList(),
+) {
+ val providerId: String
+ get() = provider.id
+
+ val providerName: String
+ get() = provider.displayName
+}
+
+data class CloudLibraryUiState(
+ val isLoaded: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val providers: List = emptyList(),
+) {
+ val items: List
+ get() = providers.flatMap { it.items }
+
+ val hasConnectedProvider: Boolean
+ get() = providers.isNotEmpty()
+}
+
+sealed interface CloudLibraryPlaybackResult {
+ data class Success(
+ val url: String,
+ val filename: String? = null,
+ val videoSizeBytes: Long? = null,
+ ) : CloudLibraryPlaybackResult
+
+ data object MissingCredentials : CloudLibraryPlaybackResult
+ data object NotPlayable : CloudLibraryPlaybackResult
+ data class Failed(val message: String? = null) : CloudLibraryPlaybackResult
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt
new file mode 100644
index 00000000..f87ca151
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt
@@ -0,0 +1,29 @@
+package com.nuvio.app.features.cloud
+
+import com.nuvio.app.features.debrid.DebridProvider
+import com.nuvio.app.features.debrid.DebridProviders
+
+internal interface CloudLibraryProviderApi {
+ val provider: DebridProvider
+
+ suspend fun listItems(apiKey: String): Result>
+
+ suspend fun resolvePlayback(
+ apiKey: String,
+ item: CloudLibraryItem,
+ file: CloudLibraryFile,
+ ): CloudLibraryPlaybackResult
+}
+
+internal object CloudLibraryProviderApis {
+ private val registered = listOf(
+ TorboxCloudLibraryProviderApi(),
+ )
+
+ fun all(): List = registered
+
+ fun apiFor(providerId: String?): CloudLibraryProviderApi? {
+ val normalized = DebridProviders.byId(providerId)?.id ?: return null
+ return registered.firstOrNull { it.provider.id == normalized }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt
new file mode 100644
index 00000000..0a2e63fe
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt
@@ -0,0 +1,134 @@
+package com.nuvio.app.features.cloud
+
+import com.nuvio.app.features.debrid.DebridProviderCapability
+import com.nuvio.app.features.debrid.DebridProviders
+import com.nuvio.app.features.debrid.DebridServiceCredential
+import com.nuvio.app.features.debrid.DebridSettingsRepository
+import com.nuvio.app.features.debrid.supports
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+internal class CloudLibraryStore(
+ private val credentialsProvider: suspend () -> List,
+ private val providerApis: List,
+) {
+ suspend fun refresh(): CloudLibraryUiState {
+ val credentials = credentialsProvider()
+ .filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
+
+ val providerStates = credentials.map { credential ->
+ val api = providerApis.firstOrNull { it.provider.id == credential.provider.id }
+ if (api == null) {
+ return@map CloudLibraryProviderState(
+ provider = credential.provider,
+ errorMessage = "Cloud library is not available for ${credential.provider.displayName}.",
+ )
+ }
+
+ api.listItems(credential.apiKey)
+ .fold(
+ onSuccess = { items ->
+ CloudLibraryProviderState(
+ provider = credential.provider,
+ items = items,
+ )
+ },
+ onFailure = { error ->
+ CloudLibraryProviderState(
+ provider = credential.provider,
+ errorMessage = error.message,
+ )
+ },
+ )
+ }
+
+ return CloudLibraryUiState(
+ isLoaded = true,
+ isRefreshing = false,
+ providers = providerStates,
+ )
+ }
+
+ suspend fun resolvePlayback(
+ item: CloudLibraryItem,
+ file: CloudLibraryFile,
+ ): CloudLibraryPlaybackResult {
+ if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable
+ val credential = credentialsProvider()
+ .firstOrNull { credential -> credential.provider.id == item.providerId }
+ ?: return CloudLibraryPlaybackResult.MissingCredentials
+ val api = providerApis.firstOrNull { it.provider.id == item.providerId }
+ ?: return CloudLibraryPlaybackResult.Failed()
+ return api.resolvePlayback(
+ apiKey = credential.apiKey,
+ item = item,
+ file = file,
+ )
+ }
+}
+
+object CloudLibraryRepository {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val store = CloudLibraryStore(
+ credentialsProvider = {
+ DebridSettingsRepository.ensureLoaded()
+ DebridProviders.configuredServices(DebridSettingsRepository.snapshot())
+ },
+ providerApis = CloudLibraryProviderApis.all(),
+ )
+ private val _uiState = MutableStateFlow(CloudLibraryUiState())
+ private var loadedConnectionKeys: List = emptyList()
+ val uiState = _uiState.asStateFlow()
+
+ fun ensureLoaded() {
+ DebridSettingsRepository.ensureLoaded()
+ val current = _uiState.value
+ if (current.isRefreshing) return
+ val connectedKeys = connectedCloudConnectionKeys()
+ if (!current.isLoaded || connectedKeys != loadedConnectionKeys) {
+ refresh()
+ }
+ }
+
+ fun refresh() {
+ _uiState.update { current ->
+ current.copy(
+ isRefreshing = true,
+ providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) },
+ )
+ }
+ scope.launch {
+ val refreshed = store.refresh()
+ loadedConnectionKeys = connectedCloudConnectionKeys()
+ _uiState.value = refreshed
+ }
+ }
+
+ suspend fun resolvePlayback(
+ item: CloudLibraryItem,
+ file: CloudLibraryFile,
+ ): CloudLibraryPlaybackResult =
+ store.resolvePlayback(item, file)
+
+ private fun connectedCloudCredentials(): List =
+ DebridProviders.configuredServices(DebridSettingsRepository.snapshot())
+ .filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
+
+ private fun connectedCloudConnectionKeys(): List =
+ connectedCloudCredentials().map { credential ->
+ CloudConnectionKey(
+ providerId = credential.provider.id,
+ apiKeyHash = credential.apiKey.hashCode(),
+ )
+ }.sortedBy { it.providerId }
+
+ private data class CloudConnectionKey(
+ val providerId: String,
+ val apiKeyHash: Int,
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt
new file mode 100644
index 00000000..43007dce
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt
@@ -0,0 +1,169 @@
+package com.nuvio.app.features.cloud
+
+import com.nuvio.app.features.debrid.DebridProviders
+import com.nuvio.app.features.debrid.TorboxApiClient
+import com.nuvio.app.features.debrid.TorboxCloudFileDto
+import com.nuvio.app.features.debrid.TorboxCloudItemDto
+import kotlinx.coroutines.CancellationException
+import kotlinx.serialization.json.JsonPrimitive
+
+internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi {
+ override val provider = DebridProviders.Torbox
+
+ override suspend fun listItems(apiKey: String): Result> =
+ runCatching {
+ val torrents = TorboxApiClient.listCloudTorrents(apiKey).itemsOrThrow(CloudLibraryItemType.Torrent)
+ val usenet = TorboxApiClient.listCloudUsenet(apiKey).itemsOrThrow(CloudLibraryItemType.Usenet)
+ val web = TorboxApiClient.listCloudWebDownloads(apiKey).itemsOrThrow(CloudLibraryItemType.WebDownload)
+ torrents + usenet + web
+ }
+
+ override suspend fun resolvePlayback(
+ apiKey: String,
+ item: CloudLibraryItem,
+ file: CloudLibraryFile,
+ ): CloudLibraryPlaybackResult {
+ if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable
+
+ return try {
+ val response = when (item.type) {
+ CloudLibraryItemType.Torrent -> TorboxApiClient.requestCloudTorrentDownloadLink(
+ apiKey = apiKey,
+ torrentId = item.id,
+ fileId = file.id,
+ )
+ CloudLibraryItemType.Usenet -> TorboxApiClient.requestCloudUsenetDownloadLink(
+ apiKey = apiKey,
+ usenetId = item.id,
+ fileId = file.id,
+ )
+ CloudLibraryItemType.WebDownload -> TorboxApiClient.requestCloudWebDownloadLink(
+ apiKey = apiKey,
+ webId = item.id,
+ fileId = file.id,
+ )
+ }
+ if (!response.isSuccessful || response.body?.success == false) {
+ return CloudLibraryPlaybackResult.Failed(response.body?.detail ?: response.body?.error)
+ }
+ val url = response.body?.data?.takeIf { it.isNotBlank() }
+ ?: return CloudLibraryPlaybackResult.Failed()
+ CloudLibraryPlaybackResult.Success(
+ url = url,
+ filename = file.name.takeIf { it.isNotBlank() },
+ videoSizeBytes = file.sizeBytes,
+ )
+ } catch (error: Exception) {
+ if (error is CancellationException) throw error
+ CloudLibraryPlaybackResult.Failed(error.message)
+ }
+ }
+
+ private fun com.nuvio.app.features.debrid.DebridApiResponse>>.itemsOrThrow(
+ type: CloudLibraryItemType,
+ ): List {
+ if (!isSuccessful || body?.success == false) {
+ throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() })
+ }
+ return body?.data.orEmpty().mapNotNull { dto ->
+ dto.toCloudLibraryItem(
+ providerId = provider.id,
+ providerName = provider.displayName,
+ type = type,
+ )
+ }
+ }
+}
+
+internal fun TorboxCloudItemDto.toCloudLibraryItem(
+ providerId: String,
+ providerName: String,
+ type: CloudLibraryItemType,
+): CloudLibraryItem? {
+ val itemId = id.scalarString()
+ ?: hash?.trim()?.takeIf { it.isNotBlank() }
+ ?: return null
+ val mappedFiles = files.orEmpty().mapNotNull { file ->
+ file.toCloudLibraryFile()
+ }
+ val filesSize = mappedFiles
+ .mapNotNull { it.sizeBytes }
+ .takeIf { it.isNotEmpty() }
+ ?.sum()
+ return CloudLibraryItem(
+ providerId = providerId,
+ providerName = providerName,
+ id = itemId,
+ type = type,
+ name = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId,
+ status = listOf(status, downloadState, state)
+ .firstNonBlank(),
+ sizeBytes = size ?: totalSize ?: filesSize,
+ progressFraction = listOfNotNull(progress, downloadProgress).firstOrNull()?.toProgressFraction(),
+ files = mappedFiles,
+ )
+}
+
+internal fun TorboxCloudFileDto.toCloudLibraryFile(): CloudLibraryFile? {
+ val name = listOf(name, shortName, absolutePath)
+ .firstNonBlank()
+ ?: return null
+ val fileId = id.scalarString()
+ val mime = listOf(mimeType, mimeTypeAlt).firstNonBlank()
+ return CloudLibraryFile(
+ id = fileId,
+ name = name,
+ sizeBytes = size,
+ mimeType = mime,
+ playable = fileId != null && isPlayableCloudFile(name = name, mimeType = mime),
+ )
+}
+
+internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String =
+ when (type) {
+ CloudLibraryItemType.Torrent -> "torrent_id"
+ CloudLibraryItemType.Usenet -> "usenet_id"
+ CloudLibraryItemType.WebDownload -> "web_id"
+ }
+
+private fun List.firstNonBlank(): String? =
+ firstOrNull { !it.isNullOrBlank() }?.trim()
+
+private fun kotlinx.serialization.json.JsonElement?.scalarString(): String? {
+ val primitive = this as? JsonPrimitive ?: return null
+ return primitive.content.trim().takeIf { it.isNotBlank() }
+}
+
+private fun Double.toProgressFraction(): Float {
+ val normalized = if (this > 1.0) this / 100.0 else this
+ return normalized.toFloat().coerceIn(0f, 1f)
+}
+
+private fun isPlayableCloudFile(name: String, mimeType: String?): Boolean {
+ val normalizedMime = mimeType?.lowercase().orEmpty()
+ if (normalizedMime.startsWith("video/")) return true
+ val extension = name.substringAfterLast('.', missingDelimiterValue = "")
+ .lowercase()
+ return extension in playableVideoExtensions
+}
+
+private val playableVideoExtensions = setOf(
+ "3g2",
+ "3gp",
+ "avi",
+ "divx",
+ "flv",
+ "m2ts",
+ "m4v",
+ "mkv",
+ "mov",
+ "mp4",
+ "mpeg",
+ "mpg",
+ "mts",
+ "ogm",
+ "ogv",
+ "ts",
+ "webm",
+ "wmv",
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt
index 47ddac07..485d59dc 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt
@@ -115,6 +115,27 @@ internal object TorboxApiClient {
apiKey = apiKey,
)
+ suspend fun listCloudTorrents(apiKey: String): DebridApiResponse>> =
+ request(
+ method = "GET",
+ url = "$BASE_URL/v1/api/torrents/mylist",
+ apiKey = apiKey,
+ )
+
+ suspend fun listCloudUsenet(apiKey: String): DebridApiResponse>> =
+ request(
+ method = "GET",
+ url = "$BASE_URL/v1/api/usenet/mylist",
+ apiKey = apiKey,
+ )
+
+ suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse>> =
+ request(
+ method = "GET",
+ url = "$BASE_URL/v1/api/webdl/mylist",
+ apiKey = apiKey,
+ )
+
suspend fun requestDownloadLink(
apiKey: String,
torrentId: Int,
@@ -135,6 +156,66 @@ internal object TorboxApiClient {
apiKey = apiKey,
)
+ suspend fun requestCloudTorrentDownloadLink(
+ apiKey: String,
+ torrentId: String,
+ fileId: String?,
+ ): DebridApiResponse> =
+ request(
+ method = "GET",
+ url = "$BASE_URL/v1/api/torrents/requestdl?${
+ queryString(
+ "token" to apiKey,
+ "torrent_id" to torrentId,
+ "file_id" to fileId,
+ "zip_link" to "false",
+ "redirect" to "false",
+ "append_name" to "false",
+ )
+ }",
+ apiKey = apiKey,
+ )
+
+ suspend fun requestCloudUsenetDownloadLink(
+ apiKey: String,
+ usenetId: String,
+ fileId: String?,
+ ): DebridApiResponse> =
+ request(
+ method = "GET",
+ url = "$BASE_URL/v1/api/usenet/requestdl?${
+ queryString(
+ "token" to apiKey,
+ "usenet_id" to usenetId,
+ "file_id" to fileId,
+ "zip_link" to "false",
+ "redirect" to "false",
+ "append_name" to "false",
+ )
+ }",
+ apiKey = apiKey,
+ )
+
+ suspend fun requestCloudWebDownloadLink(
+ apiKey: String,
+ webId: String,
+ fileId: String?,
+ ): DebridApiResponse> =
+ request(
+ method = "GET",
+ url = "$BASE_URL/v1/api/webdl/requestdl?${
+ queryString(
+ "token" to apiKey,
+ "web_id" to webId,
+ "file_id" to fileId,
+ "zip_link" to "false",
+ "redirect" to "false",
+ "append_name" to "false",
+ )
+ }",
+ apiKey = apiKey,
+ )
+
private suspend inline fun request(
method: String,
url: String,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt
index 909fd46a..a2c27de2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt
@@ -2,6 +2,7 @@ package com.nuvio.app.features.debrid
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
@Serializable
internal data class TorboxEnvelopeDto(
@@ -44,6 +45,32 @@ internal data class TorboxTorrentFileDto(
.orEmpty()
}
+@Serializable
+internal data class TorboxCloudItemDto(
+ val id: JsonElement? = null,
+ val hash: String? = null,
+ val name: String? = null,
+ val status: String? = null,
+ val state: String? = null,
+ @SerialName("download_state") val downloadState: String? = null,
+ val progress: Double? = null,
+ @SerialName("download_progress") val downloadProgress: Double? = null,
+ val size: Long? = null,
+ @SerialName("total_size") val totalSize: Long? = null,
+ val files: List? = null,
+)
+
+@Serializable
+internal data class TorboxCloudFileDto(
+ val id: JsonElement? = 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,
+ @SerialName("mime_type") val mimeTypeAlt: String? = null,
+ val size: Long? = null,
+)
+
@Serializable
internal data class TorboxCheckCachedRequestDto(
val hashes: List,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt
index ac6aa352..93e75362 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt
@@ -18,6 +18,7 @@ enum class DebridProviderCapability {
ClientResolve,
LocalTorrentCacheCheck,
LocalTorrentResolve,
+ CloudLibrary,
}
enum class DebridProviderAuthMethod {
@@ -38,6 +39,7 @@ object DebridProviders {
DebridProviderCapability.ClientResolve,
DebridProviderCapability.LocalTorrentCacheCheck,
DebridProviderCapability.LocalTorrentResolve,
+ DebridProviderCapability.CloudLibrary,
),
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
index 863fa3b4..61ca78cd 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
@@ -1,25 +1,60 @@
package com.nuvio.app.features.library
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.nuvio.app.core.i18n.localizedByteUnit
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioScreen
@@ -28,6 +63,11 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
+import com.nuvio.app.features.cloud.CloudLibraryFile
+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.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomePosterCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
@@ -47,17 +87,30 @@ fun LibraryScreen(
onPosterClick: ((LibraryItem) -> Unit)? = null,
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
onSectionViewAllClick: ((LibrarySection) -> Unit)? = null,
+ onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
+ onConnectCloudClick: (() -> Unit)? = null,
) {
val uiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
+ val cloudUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by remember {
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
var observedOfflineState by remember { mutableStateOf(false) }
+ var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) }
+ val sourceMode = remember(sourceModeName) {
+ runCatching { LibraryViewMode.valueOf(sourceModeName) }.getOrDefault(LibraryViewMode.Saved)
+ }
+ var selectedProviderId by rememberSaveable { mutableStateOf(null) }
+ var selectedTypeName by rememberSaveable { mutableStateOf(null) }
+ val selectedType = remember(selectedTypeName) {
+ selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() }
+ }
+ var selectedCloudItemKey by rememberSaveable { mutableStateOf(null) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
@@ -98,6 +151,13 @@ fun LibraryScreen(
}
}
+ LaunchedEffect(sourceMode) {
+ if (sourceMode == LibraryViewMode.Cloud) {
+ CloudLibraryRepository.ensureLoaded()
+ selectedCloudItemKey = null
+ }
+ }
+
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
@@ -111,87 +171,772 @@ fun LibraryScreen(
.background(MaterialTheme.colorScheme.background),
) {
NuvioScreenHeader(
- title = if (isTraktSource) {
+ title = if (sourceMode == LibraryViewMode.Cloud) {
+ stringResource(Res.string.library_title)
+ } else if (isTraktSource) {
stringResource(Res.string.library_trakt_title)
} else {
stringResource(Res.string.library_title)
},
modifier = Modifier.padding(horizontal = 16.dp),
)
+ LibrarySourceSwitch(
+ selectedMode = sourceMode,
+ onModeSelected = { mode ->
+ sourceModeName = mode.name
+ },
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
Spacer(modifier = Modifier.height(6.dp))
}
}
- when {
- !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
- items(3) {
- HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
+ if (sourceMode == LibraryViewMode.Cloud) {
+ cloudLibraryContent(
+ uiState = cloudUiState,
+ selectedProviderId = selectedProviderId,
+ selectedType = selectedType,
+ selectedCloudItemKey = selectedCloudItemKey,
+ onProviderSelected = {
+ selectedProviderId = it
+ selectedCloudItemKey = null
+ },
+ onTypeSelected = {
+ selectedTypeName = it?.name
+ selectedCloudItemKey = null
+ },
+ onItemSelected = { item ->
+ val playableFiles = item.playableFiles
+ when {
+ playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first())
+ playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey
+ }
+ },
+ onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) },
+ onBackToItems = { selectedCloudItemKey = null },
+ onRefresh = { CloudLibraryRepository.refresh() },
+ onConnectCloudClick = onConnectCloudClick,
+ )
+ } else {
+ when {
+ !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
+ items(3) {
+ HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ }
+
+ !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
+ item {
+ if (networkStatusUiState.isOfflineLike) {
+ NuvioNetworkOfflineCard(
+ condition = networkStatusUiState.condition,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ onRetry = retryLibraryLoad,
+ )
+ } else {
+ HomeEmptyStateCard(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ title = if (isTraktSource) {
+ stringResource(Res.string.library_trakt_load_failed)
+ } else {
+ stringResource(Res.string.library_load_failed)
+ },
+ message = uiState.errorMessage.orEmpty(),
+ actionLabel = stringResource(Res.string.action_retry),
+ onActionClick = retryLibraryLoad,
+ )
+ }
+ }
+ }
+
+ uiState.sections.isEmpty() -> {
+ item {
+ if (networkStatusUiState.isOfflineLike && isTraktSource) {
+ NuvioNetworkOfflineCard(
+ condition = networkStatusUiState.condition,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ onRetry = retryLibraryLoad,
+ )
+ } else {
+ HomeEmptyStateCard(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ title = if (isTraktSource) {
+ stringResource(Res.string.library_trakt_empty_title)
+ } else {
+ stringResource(Res.string.library_empty_title)
+ },
+ message = if (isTraktSource) {
+ stringResource(Res.string.library_trakt_empty_message)
+ } else {
+ stringResource(Res.string.library_empty_message)
+ },
+ )
+ }
+ }
+ }
+
+ else -> {
+ librarySections(
+ sections = uiState.sections,
+ watchedKeys = watchedUiState.watchedKeys,
+ onPosterClick = onPosterClick,
+ onSectionViewAllClick = onSectionViewAllClick,
+ onPosterLongClick = onPosterLongClick,
+ )
}
}
+ }
+ }
+}
- !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
+private fun LazyListScope.cloudLibraryContent(
+ uiState: CloudLibraryUiState,
+ selectedProviderId: String?,
+ selectedType: CloudLibraryItemType?,
+ selectedCloudItemKey: String?,
+ onProviderSelected: (String?) -> Unit,
+ onTypeSelected: (CloudLibraryItemType?) -> Unit,
+ onItemSelected: (CloudLibraryItem) -> Unit,
+ onFileSelected: (CloudLibraryItem, CloudLibraryFile) -> Unit,
+ onBackToItems: () -> Unit,
+ onRefresh: () -> Unit,
+ onConnectCloudClick: (() -> Unit)?,
+) {
+ when {
+ !uiState.isLoaded -> {
+ cloudLibrarySkeletonItems()
+ }
+
+ !uiState.hasConnectedProvider -> {
+ item {
+ HomeEmptyStateCard(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ title = stringResource(Res.string.cloud_library_connect_title),
+ message = stringResource(Res.string.cloud_library_connect_message),
+ actionLabel = stringResource(Res.string.cloud_library_connect_action),
+ onActionClick = onConnectCloudClick,
+ )
+ }
+ }
+
+ else -> {
+ val filteredItems = uiState.items
+ .filter { item -> selectedProviderId == null || item.providerId == selectedProviderId }
+ .filter { item -> selectedType == null || item.type == selectedType }
+ val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey }
+
+ if (selectedItem != null) {
item {
- if (networkStatusUiState.isOfflineLike) {
- NuvioNetworkOfflineCard(
- condition = networkStatusUiState.condition,
- modifier = Modifier.padding(horizontal = 16.dp),
- onRetry = retryLibraryLoad,
- )
- } else {
+ CloudLibraryFilePicker(
+ item = selectedItem,
+ onBack = onBackToItems,
+ onFileSelected = { file -> onFileSelected(selectedItem, file) },
+ )
+ }
+ } else {
+ item {
+ CloudLibraryToolbar(
+ uiState = uiState,
+ selectedProviderId = selectedProviderId,
+ selectedType = selectedType,
+ onProviderSelected = onProviderSelected,
+ onTypeSelected = onTypeSelected,
+ onRefresh = onRefresh,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ }
+
+ uiState.providers
+ .filter { providerState -> selectedProviderId == null || providerState.providerId == selectedProviderId }
+ .filter { providerState -> !providerState.errorMessage.isNullOrBlank() && providerState.items.isEmpty() }
+ .forEach { providerState ->
+ item(key = "cloud-error-${providerState.providerId}") {
+ HomeEmptyStateCard(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ title = stringResource(Res.string.cloud_library_load_failed, providerState.providerName),
+ message = providerState.errorMessage.orEmpty(),
+ actionLabel = stringResource(Res.string.action_retry),
+ onActionClick = onRefresh,
+ )
+ }
+ }
+
+ if (uiState.isRefreshing && filteredItems.isEmpty()) {
+ cloudLibrarySkeletonItems()
+ } else if (filteredItems.isEmpty()) {
+ item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
- title = if (isTraktSource) {
- stringResource(Res.string.library_trakt_load_failed)
- } else {
- stringResource(Res.string.library_load_failed)
- },
- message = uiState.errorMessage.orEmpty(),
+ title = stringResource(Res.string.cloud_library_empty_title),
+ message = stringResource(Res.string.cloud_library_empty_message),
actionLabel = stringResource(Res.string.action_retry),
- onActionClick = retryLibraryLoad,
+ onActionClick = onRefresh,
+ )
+ }
+ } else {
+ items(
+ items = filteredItems,
+ key = { item -> item.stableKey },
+ ) { item ->
+ CloudLibraryRow(
+ item = item,
+ onClick = { onItemSelected(item) },
)
}
}
}
+ }
+ }
+}
- uiState.sections.isEmpty() -> {
+private fun LazyListScope.cloudLibrarySkeletonItems() {
+ item(key = "cloud-library-skeleton-toolbar") {
+ CloudLibrarySkeletonToolbar(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ }
+ items(4) {
+ CloudLibrarySkeletonRow()
+ }
+}
+
+@Composable
+private fun LibrarySourceSwitch(
+ selectedMode: LibraryViewMode,
+ onModeSelected: (LibraryViewMode) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ LibraryChip(
+ label = stringResource(Res.string.library_source_saved),
+ selected = selectedMode == LibraryViewMode.Saved,
+ onClick = { onModeSelected(LibraryViewMode.Saved) },
+ )
+ LibraryChip(
+ label = stringResource(Res.string.library_source_cloud),
+ selected = selectedMode == LibraryViewMode.Cloud,
+ onClick = { onModeSelected(LibraryViewMode.Cloud) },
+ )
+ }
+}
+
+@Composable
+private fun CloudLibraryToolbar(
+ uiState: CloudLibraryUiState,
+ selectedProviderId: String?,
+ selectedType: CloudLibraryItemType?,
+ onProviderSelected: (String?) -> Unit,
+ onTypeSelected: (CloudLibraryItemType?) -> Unit,
+ onRefresh: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ LazyRow(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(end = 8.dp),
+ ) {
item {
- if (networkStatusUiState.isOfflineLike && isTraktSource) {
- NuvioNetworkOfflineCard(
- condition = networkStatusUiState.condition,
- modifier = Modifier.padding(horizontal = 16.dp),
- onRetry = retryLibraryLoad,
- )
- } else {
- HomeEmptyStateCard(
- modifier = Modifier.padding(horizontal = 16.dp),
- title = if (isTraktSource) {
- stringResource(Res.string.library_trakt_empty_title)
- } else {
- stringResource(Res.string.library_empty_title)
- },
- message = if (isTraktSource) {
- stringResource(Res.string.library_trakt_empty_message)
- } else {
- stringResource(Res.string.library_empty_message)
- },
- )
- }
+ LibraryChip(
+ label = stringResource(Res.string.cloud_library_provider_all),
+ selected = selectedProviderId == null,
+ onClick = { onProviderSelected(null) },
+ )
+ }
+ items(
+ items = uiState.providers,
+ key = { provider -> provider.providerId },
+ ) { provider ->
+ LibraryChip(
+ label = provider.providerName,
+ selected = selectedProviderId == provider.providerId,
+ loading = provider.isLoading,
+ error = !provider.errorMessage.isNullOrBlank(),
+ onClick = { onProviderSelected(provider.providerId) },
+ )
}
}
-
- else -> {
- librarySections(
- sections = uiState.sections,
- watchedKeys = watchedUiState.watchedKeys,
- onPosterClick = onPosterClick,
- onSectionViewAllClick = onSectionViewAllClick,
- onPosterLongClick = onPosterLongClick,
+ IconButton(onClick = onRefresh) {
+ Icon(
+ imageVector = Icons.Rounded.Refresh,
+ contentDescription = stringResource(Res.string.cloud_library_refresh),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(end = 16.dp),
+ ) {
+ item {
+ LibraryChip(
+ label = stringResource(Res.string.cloud_library_type_all),
+ selected = selectedType == null,
+ onClick = { onTypeSelected(null) },
+ )
+ }
+ items(
+ items = CloudLibraryItemType.entries,
+ key = { type -> type.name },
+ ) { type ->
+ LibraryChip(
+ label = cloudLibraryTypeLabel(type),
+ selected = selectedType == type,
+ onClick = { onTypeSelected(type) },
)
}
}
}
}
+@Composable
+private fun LibraryChip(
+ label: String,
+ selected: Boolean,
+ loading: Boolean = false,
+ error: Boolean = false,
+ onClick: () -> Unit,
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ Surface(
+ modifier = Modifier
+ .clip(RoundedCornerShape(18.dp))
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(18.dp),
+ color = if (selected) colorScheme.primaryContainer else colorScheme.surfaceContainerLow,
+ border = if (selected) BorderStroke(1.dp, colorScheme.primary.copy(alpha = 0.45f)) else null,
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ if (loading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(12.dp),
+ strokeWidth = 1.5.dp,
+ color = colorScheme.primary,
+ )
+ }
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = when {
+ error -> colorScheme.error
+ selected -> colorScheme.onPrimaryContainer
+ else -> colorScheme.onSurfaceVariant
+ },
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun CloudLibraryRow(
+ item: CloudLibraryItem,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val playableCount = item.playableFiles.size
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 6.dp)
+ .clickable(enabled = playableCount > 0, onClick = onClick),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = item.name,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = cloudLibrarySubtitle(item),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = cloudLibraryStatusLine(item),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (playableCount > 0) {
+ IconButton(onClick = onClick) {
+ Icon(
+ imageVector = Icons.Rounded.PlayArrow,
+ contentDescription = stringResource(Res.string.action_play),
+ )
+ }
+ }
+ }
+ item.progressFraction?.takeIf { it in 0f..0.999f }?.let { progress ->
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CloudLibraryFilePicker(
+ item: CloudLibraryItem,
+ onBack: () -> Unit,
+ onFileSelected: (CloudLibraryFile) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(Res.string.action_back),
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.name,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = stringResource(Res.string.cloud_library_file_picker_title),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ val files = item.playableFiles
+ if (files.isEmpty()) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.cloud_library_no_files_title),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = stringResource(Res.string.cloud_library_no_files_message),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ files.forEach { file ->
+ CloudLibraryFileRow(
+ file = file,
+ onClick = { onFileSelected(file) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CloudLibraryFileRow(
+ file: CloudLibraryFile,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ 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),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.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 ->
+ Text(
+ text = formatCloudBytes(size),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ Icon(
+ imageVector = Icons.Rounded.PlayArrow,
+ contentDescription = stringResource(Res.string.cloud_library_play_file),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+}
+
+@Composable
+private fun cloudLibrarySubtitle(item: CloudLibraryItem): String {
+ val fileLine = when (val playableCount = item.playableFiles.size) {
+ 0 -> stringResource(Res.string.cloud_library_no_playable_files)
+ 1 -> item.playableFiles.first().name
+ else -> stringResource(Res.string.cloud_library_playable_file_count, playableCount)
+ }
+ return listOf(item.providerName, cloudLibraryTypeLabel(item.type), fileLine).joinToString(" • ")
+}
+
+@Composable
+private fun cloudLibraryStatusLine(item: CloudLibraryItem): String {
+ val fallback = if (item.playableFiles.isEmpty()) {
+ stringResource(Res.string.cloud_library_no_playable_files)
+ } else {
+ stringResource(Res.string.cloud_library_status_ready)
+ }
+ return listOfNotNull(
+ item.status?.toDisplayStatus(),
+ item.sizeBytes?.let(::formatCloudBytes),
+ item.progressFraction?.let { "${(it * 100f).toInt()}%" },
+ ).joinToString(" • ").ifBlank { fallback }
+}
+
+@Composable
+private fun cloudLibraryTypeLabel(type: CloudLibraryItemType): String =
+ when (type) {
+ CloudLibraryItemType.Torrent -> stringResource(Res.string.cloud_library_type_torrents)
+ CloudLibraryItemType.Usenet -> stringResource(Res.string.cloud_library_type_usenet)
+ CloudLibraryItemType.WebDownload -> stringResource(Res.string.cloud_library_type_web)
+ }
+
+private fun formatCloudBytes(bytes: Long): String {
+ if (bytes <= 0L) return "0 ${localizedByteUnit("B")}"
+ val kib = 1024.0
+ val mib = kib * 1024.0
+ val gib = mib * 1024.0
+ val value = bytes.toDouble()
+ return when {
+ value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}"
+ value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}"
+ value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}"
+ else -> "$bytes ${localizedByteUnit("B")}"
+ }
+}
+
+private fun String.toDisplayStatus(): String =
+ replace('_', ' ')
+ .lowercase()
+ .replaceFirstChar { it.titlecase() }
+
+@Composable
+private fun CloudLibrarySkeletonToolbar(
+ modifier: Modifier = Modifier,
+) {
+ val brush = rememberCloudLibrarySkeletonBrush()
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CloudSkeletonBlock(brush = brush, width = 52.dp, height = 34.dp, cornerRadius = 18.dp)
+ CloudSkeletonBlock(brush = brush, width = 86.dp, height = 34.dp, cornerRadius = 18.dp)
+ CloudSkeletonBlock(
+ brush = brush,
+ modifier = Modifier.weight(1f),
+ height = 34.dp,
+ cornerRadius = 18.dp,
+ )
+ CloudSkeletonBlock(brush = brush, width = 40.dp, height = 40.dp, cornerRadius = 20.dp)
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CloudSkeletonBlock(brush = brush, width = 52.dp, height = 34.dp, cornerRadius = 18.dp)
+ CloudSkeletonBlock(brush = brush, width = 82.dp, height = 34.dp, cornerRadius = 18.dp)
+ CloudSkeletonBlock(brush = brush, width = 72.dp, height = 34.dp, cornerRadius = 18.dp)
+ CloudSkeletonBlock(brush = brush, width = 60.dp, height = 34.dp, cornerRadius = 18.dp)
+ }
+ }
+}
+
+@Composable
+private fun CloudLibrarySkeletonRow(
+ modifier: Modifier = Modifier,
+) {
+ val brush = rememberCloudLibrarySkeletonBrush()
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(7.dp),
+ ) {
+ CloudSkeletonBlock(
+ brush = brush,
+ modifier = Modifier.fillMaxWidth(0.74f),
+ height = 17.dp,
+ cornerRadius = 6.dp,
+ )
+ CloudSkeletonBlock(
+ brush = brush,
+ modifier = Modifier.fillMaxWidth(),
+ height = 12.dp,
+ cornerRadius = 6.dp,
+ )
+ CloudSkeletonBlock(
+ brush = brush,
+ modifier = Modifier.fillMaxWidth(0.58f),
+ height = 12.dp,
+ cornerRadius = 6.dp,
+ )
+ }
+ CloudSkeletonBlock(brush = brush, width = 32.dp, height = 32.dp, cornerRadius = 16.dp)
+ }
+ CloudSkeletonBlock(
+ brush = brush,
+ modifier = Modifier.fillMaxWidth(0.44f),
+ height = 4.dp,
+ cornerRadius = 999.dp,
+ )
+ }
+ }
+}
+
+@Composable
+private fun rememberCloudLibrarySkeletonBrush(): Brush {
+ val shimmerColors = listOf(
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f),
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
+ )
+ val transition = rememberInfiniteTransition()
+ val translateAnim by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1000f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1200, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart,
+ ),
+ )
+ return Brush.linearGradient(
+ colors = shimmerColors,
+ start = Offset(translateAnim - 200f, 0f),
+ end = Offset(translateAnim, 0f),
+ )
+}
+
+@Composable
+private fun CloudSkeletonBlock(
+ brush: Brush,
+ modifier: Modifier = Modifier,
+ width: Dp? = null,
+ height: Dp,
+ cornerRadius: Dp,
+) {
+ val sizeModifier = if (width != null) {
+ modifier.size(width = width, height = height)
+ } else {
+ modifier.height(height)
+ }
+ Box(
+ modifier = sizeModifier
+ .clip(RoundedCornerShape(cornerRadius))
+ .background(brush),
+ )
+}
+
+private enum class LibraryViewMode {
+ Saved,
+ Cloud,
+}
+
private fun LazyListScope.librarySections(
sections: List,
watchedKeys: Set,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
index 21442208..fb85bc73 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
@@ -94,6 +94,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L
fun SettingsScreen(
modifier: Modifier = Modifier,
rootActionRequests: Flow = emptyFlow(),
+ requestedPageName: String? = null,
+ onRequestedPageConsumed: () -> Unit = {},
rootActionsEnabled: Boolean = true,
onSwitchProfile: (() -> Unit)? = null,
onHomescreenClick: () -> Unit = {},
@@ -221,6 +223,15 @@ fun SettingsScreen(
}
}
+ LaunchedEffect(requestedPageName, rootActionsEnabled) {
+ val targetPage = requestedPageName
+ ?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() }
+ ?: return@LaunchedEffect
+ if (!rootActionsEnabled) return@LaunchedEffect
+ currentPage = targetPage.name
+ onRequestedPageConsumed()
+ }
+
PlatformBackHandler(
enabled = rootActionsEnabled && previousPage != null,
onBack = { previousPage?.let { currentPage = it.name } },
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt
new file mode 100644
index 00000000..297daf49
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt
@@ -0,0 +1,110 @@
+package com.nuvio.app.features.cloud
+
+import com.nuvio.app.features.debrid.DebridProvider
+import com.nuvio.app.features.debrid.DebridProviderCapability
+import com.nuvio.app.features.debrid.DebridServiceCredential
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class CloudLibraryStoreTest {
+ @Test
+ fun `refresh aggregates multiple providers without provider-specific assumptions`() = runBlocking {
+ val firstProvider = cloudProvider(id = "alpha", name = "Alpha")
+ val secondProvider = cloudProvider(id = "beta", name = "Beta")
+ val store = CloudLibraryStore(
+ credentialsProvider = {
+ listOf(
+ DebridServiceCredential(firstProvider, "alpha-token"),
+ DebridServiceCredential(secondProvider, "beta-token"),
+ )
+ },
+ providerApis = listOf(
+ FakeCloudProviderApi(
+ provider = firstProvider,
+ items = listOf(cloudItem(firstProvider, "one")),
+ ),
+ FakeCloudProviderApi(
+ provider = secondProvider,
+ items = listOf(cloudItem(secondProvider, "two")),
+ ),
+ ),
+ )
+
+ val state = store.refresh()
+
+ assertTrue(state.isLoaded)
+ assertEquals(listOf("alpha", "beta"), state.providers.map { it.providerId })
+ assertEquals(listOf("one", "two"), state.items.map { it.id })
+ }
+
+ @Test
+ fun `refresh ignores connected providers without cloud library capability`() = runBlocking {
+ val cloudProvider = cloudProvider(id = "cloud", name = "Cloud")
+ val unsupportedProvider = DebridProvider(
+ id = "plain",
+ displayName = "Plain",
+ shortName = "P",
+ capabilities = setOf(DebridProviderCapability.ClientResolve),
+ )
+ val store = CloudLibraryStore(
+ credentialsProvider = {
+ listOf(
+ DebridServiceCredential(cloudProvider, "cloud-token"),
+ DebridServiceCredential(unsupportedProvider, "plain-token"),
+ )
+ },
+ providerApis = listOf(
+ FakeCloudProviderApi(
+ provider = cloudProvider,
+ items = listOf(cloudItem(cloudProvider, "cloud-item")),
+ ),
+ ),
+ )
+
+ val state = store.refresh()
+
+ assertEquals(listOf("cloud"), state.providers.map { it.providerId })
+ assertEquals(listOf("cloud-item"), state.items.map { it.id })
+ }
+}
+
+private class FakeCloudProviderApi(
+ override val provider: DebridProvider,
+ private val items: List,
+) : CloudLibraryProviderApi {
+ override suspend fun listItems(apiKey: String): Result> =
+ Result.success(items)
+
+ override suspend fun resolvePlayback(
+ apiKey: String,
+ item: CloudLibraryItem,
+ file: CloudLibraryFile,
+ ): CloudLibraryPlaybackResult =
+ CloudLibraryPlaybackResult.Success(url = "https://example.test/${item.id}/${file.id}")
+}
+
+private fun cloudProvider(id: String, name: String): DebridProvider =
+ DebridProvider(
+ id = id,
+ displayName = name,
+ shortName = name.take(1),
+ capabilities = setOf(DebridProviderCapability.CloudLibrary),
+ )
+
+private fun cloudItem(provider: DebridProvider, id: String): CloudLibraryItem =
+ CloudLibraryItem(
+ providerId = provider.id,
+ providerName = provider.displayName,
+ id = id,
+ type = CloudLibraryItemType.Torrent,
+ name = id,
+ files = listOf(
+ CloudLibraryFile(
+ id = "file-$id",
+ name = "$id.mkv",
+ playable = true,
+ ),
+ ),
+ )
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt
new file mode 100644
index 00000000..7648b8d3
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt
@@ -0,0 +1,131 @@
+package com.nuvio.app.features.cloud
+
+import com.nuvio.app.features.debrid.DebridProviders
+import com.nuvio.app.features.debrid.TorboxCloudFileDto
+import com.nuvio.app.features.debrid.TorboxCloudItemDto
+import kotlinx.serialization.json.JsonPrimitive
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class TorboxCloudLibraryProviderApiTest {
+ @Test
+ fun `maps torrent dto with status progress size and playable files`() {
+ val item = TorboxCloudItemDto(
+ id = JsonPrimitive(42),
+ name = "Movie Pack",
+ status = "completed",
+ progress = 75.0,
+ size = 1_024L,
+ files = listOf(
+ TorboxCloudFileDto(
+ id = JsonPrimitive(8),
+ name = "movie.mkv",
+ mimeType = "video/x-matroska",
+ size = 512L,
+ ),
+ ),
+ ).toCloudLibraryItem(
+ providerId = DebridProviders.Torbox.id,
+ providerName = DebridProviders.Torbox.displayName,
+ type = CloudLibraryItemType.Torrent,
+ )
+
+ assertNotNull(item)
+ assertEquals("42", item.id)
+ assertEquals(CloudLibraryItemType.Torrent, item.type)
+ assertEquals("completed", item.status)
+ assertEquals(0.75f, item.progressFraction)
+ assertEquals(1_024L, item.sizeBytes)
+ assertEquals(listOf("8"), item.files.map { it.id })
+ assertTrue(item.files.single().playable)
+ }
+
+ @Test
+ fun `mapping falls back to hash and file absolute path when friendly fields are missing`() {
+ val item = TorboxCloudItemDto(
+ hash = "abc123",
+ files = listOf(
+ TorboxCloudFileDto(
+ id = JsonPrimitive("file-1"),
+ absolutePath = "/downloads/show.mp4",
+ size = 256L,
+ ),
+ ),
+ ).toCloudLibraryItem(
+ providerId = "torbox",
+ providerName = "Torbox",
+ type = CloudLibraryItemType.Usenet,
+ )
+
+ assertNotNull(item)
+ assertEquals("abc123", item.id)
+ assertEquals("abc123", item.name)
+ assertEquals("/downloads/show.mp4", item.files.single().name)
+ assertTrue(item.files.single().playable)
+ }
+
+ @Test
+ fun `mapping handles missing item ids and empty file lists`() {
+ assertNull(
+ TorboxCloudItemDto(name = "No ID").toCloudLibraryItem(
+ providerId = "torbox",
+ providerName = "Torbox",
+ type = CloudLibraryItemType.WebDownload,
+ ),
+ )
+
+ val item = TorboxCloudItemDto(
+ id = JsonPrimitive(7),
+ name = "Empty",
+ files = emptyList(),
+ ).toCloudLibraryItem(
+ providerId = "torbox",
+ providerName = "Torbox",
+ type = CloudLibraryItemType.WebDownload,
+ )
+
+ assertNotNull(item)
+ assertTrue(item.files.isEmpty())
+ assertTrue(item.playableFiles.isEmpty())
+ }
+
+ @Test
+ fun `mapping keeps non-playable files but excludes them from playable files`() {
+ val item = TorboxCloudItemDto(
+ id = JsonPrimitive(9),
+ name = "Mixed",
+ files = listOf(
+ TorboxCloudFileDto(
+ id = JsonPrimitive(1),
+ name = "readme.txt",
+ mimeType = "text/plain",
+ ),
+ TorboxCloudFileDto(
+ name = "missing-id.mkv",
+ mimeType = "video/x-matroska",
+ ),
+ ),
+ ).toCloudLibraryItem(
+ providerId = "torbox",
+ providerName = "Torbox",
+ type = CloudLibraryItemType.Torrent,
+ )
+
+ assertNotNull(item)
+ assertEquals(2, item.files.size)
+ assertFalse(item.files[0].playable)
+ assertFalse(item.files[1].playable)
+ assertTrue(item.playableFiles.isEmpty())
+ }
+
+ @Test
+ fun `request download parameter names match Torbox item type`() {
+ assertEquals("torrent_id", torboxRequestIdParameterName(CloudLibraryItemType.Torrent))
+ assertEquals("usenet_id", torboxRequestIdParameterName(CloudLibraryItemType.Usenet))
+ assertEquals("web_id", torboxRequestIdParameterName(CloudLibraryItemType.WebDownload))
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt
index 2f0eccd2..31c65109 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt
@@ -11,6 +11,7 @@ class DebridProviderTest {
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve))
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck))
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve))
+ assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.CloudLibrary))
}
@Test
@@ -20,5 +21,6 @@ class DebridProviderTest {
assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve))
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck))
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentResolve))
+ assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.CloudLibrary))
}
}