From 2a550c43567f39871fa170d1f020fc7006503c48 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 13:16:22 +0530 Subject: [PATCH] feat: debrid hybrid approach --- composeApp/build.gradle.kts | 13 - .../composeResources/values/strings.xml | 1 + .../commonMain/kotlin/com/nuvio/app/App.kt | 50 +-- .../app/features/debrid/DebridApiClients.kt | 28 ++ .../app/features/debrid/DebridApiModels.kt | 13 +- .../features/debrid/DebridMagnetBuilder.kt | 37 +++ .../features/debrid/DebridStreamFormatter.kt | 90 ++++-- .../debrid/DebridStreamFormatterDefaults.kt | 3 +- ...mFilter.kt => DebridStreamPresentation.kt} | 290 +++++++++--------- .../debrid/DebridStreamTemplateEngine.kt | 6 +- .../debrid/DirectDebridConfigEncoder.kt | 39 --- .../features/debrid/DirectDebridResolver.kt | 164 +++++++++- .../debrid/DirectDebridStreamPreparer.kt | 16 +- .../debrid/DirectDebridStreamSource.kt | 253 --------------- .../debrid/TorboxAvailabilityService.kt | 111 +++++++ .../app/features/details/MetaDetailsScreen.kt | 16 - .../features/downloads/DownloadsRepository.kt | 2 +- .../features/player/PlayerEpisodesPanel.kt | 11 +- .../nuvio/app/features/player/PlayerScreen.kt | 8 +- .../app/features/player/PlayerSourcesPanel.kt | 16 +- .../player/PlayerStreamsRepository.kt | 74 ++--- .../features/settings/DebridSettingsPage.kt | 4 +- .../streams/StreamAutoPlaySelector.kt | 15 +- .../app/features/streams/StreamModels.kt | 33 ++ .../app/features/streams/StreamsRepository.kt | 97 +++--- .../app/features/streams/StreamsScreen.kt | 81 ++++- .../features/streams/StreamsTabletLayout.kt | 2 + .../debrid/DebridStreamFormatterTest.kt | 122 -------- .../debrid/DebridStreamPresentationTest.kt | 103 +++++++ .../debrid/DebridStreamTemplateEngineTest.kt | 45 --- .../debrid/DirectDebridConfigEncoderTest.kt | 27 -- .../debrid/DirectDebridStreamFilterTest.kt | 210 ------------- .../app/features/streams/StreamParserTest.kt | 2 +- iosApp/Configuration/Version.xcconfig | 2 +- 34 files changed, 895 insertions(+), 1089 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt rename composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/{DirectDebridStreamFilter.kt => DebridStreamPresentation.kt} (84%) delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 89ebdf46..5c5811e4 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -90,19 +90,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } - outDir.resolve("com/nuvio/app/features/debrid").apply { - mkdirs() - resolve("DebridConfig.kt").writeText( - """ - |package com.nuvio.app.features.debrid - | - |object DebridConfig { - | const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}" - |} - """.trimMargin() - ) - } - outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ddbb9b65..fe97f276 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1153,6 +1153,7 @@ SIZE %1$s This stream type is not supported Add a Debrid API key in Settings. + Not cached on Torbox. This Debrid result expired. Refreshing streams. Could not resolve this Debrid stream. Couldn't open external player diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118..3d5e7c37 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1512,30 +1512,34 @@ private fun MainAppContent( if (autoPlayHandled) return@LaunchedEffect if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect - val stream = when ( - val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( - stream = selectedStream, - season = launch.seasonNumber, - episode = launch.episodeNumber, - ) - ) { - is DirectDebridPlayableResult.Success -> resolved.stream - else -> { - resolved.toastMessage()?.let { NuvioToastController.show(it) } - StreamsRepository.consumeAutoPlay() - if (resolved == DirectDebridPlayableResult.Stale) { - StreamsRepository.reload( - type = launch.type, - videoId = effectiveVideoId, - season = launch.seasonNumber, - episode = launch.episodeNumber, - manualSelection = launch.manualSelection, - ) + val stream = if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(selectedStream)) { + when ( + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = selectedStream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + ) { + is DirectDebridPlayableResult.Success -> resolved.stream + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + StreamsRepository.consumeAutoPlay() + if (resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + return@LaunchedEffect } - return@LaunchedEffect } + } else { + selectedStream } - val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect + val sourceUrl = stream.playableDirectUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -1612,7 +1616,7 @@ private fun MainAppContent( forceExternal: Boolean, forceInternal: Boolean, ) { - if (stream.isDirectDebridStream && stream.directPlaybackUrl == null) { + if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) { if (resolvingDebridStream) return streamRouteScope.launch { resolvingDebridStream = true @@ -1646,7 +1650,7 @@ private fun MainAppContent( } return } - val sourceUrl = stream.directPlaybackUrl ?: return + val sourceUrl = stream.playableDirectUrl ?: return if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( type = launch.type, 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 cc89019a..88d3c32a 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 @@ -5,6 +5,7 @@ import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json internal data class DebridApiResponse( @@ -38,6 +39,33 @@ internal object TorboxApiClient { body = "", ) + suspend fun checkCached( + apiKey: String, + hashes: List, + ): DebridApiResponse>> { + val normalizedHashes = hashes + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + .distinct() + if (normalizedHashes.isEmpty()) { + return DebridApiResponse( + status = 200, + body = TorboxEnvelopeDto(success = true, data = emptyMap()), + rawBody = "", + ) + } + val body = DebridApiJson.json.encodeToString( + TorboxCheckCachedRequestDto(hashes = normalizedHashes), + ) + return request( + method = "POST", + url = "$BASE_URL/v1/api/torrents/checkcached?format=object", + apiKey = apiKey, + body = body, + contentType = "application/json", + ) + } + suspend fun createTorrent(apiKey: String, magnet: String): DebridApiResponse> { val boundary = "NuvioDebrid${magnet.hashCode().toUInt()}" val body = multipartFormBody( 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 50a89fde..ff74b44d 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 @@ -44,6 +44,18 @@ internal data class TorboxTorrentFileDto( .orEmpty() } +@Serializable +internal data class TorboxCheckCachedRequestDto( + val hashes: List, +) + +@Serializable +internal data class TorboxCachedItemDto( + val name: String? = null, + val size: Long? = null, + val hash: String? = null, +) + @Serializable internal data class RealDebridAddTorrentDto( val id: String? = null, @@ -91,4 +103,3 @@ internal data class RealDebridUnrestrictLinkDto( val streamable: Int? = null, val type: String? = null, ) - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt new file mode 100644 index 00000000..e45d32bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem + +internal object DebridMagnetBuilder { + fun fromStream(stream: StreamItem): String? { + stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it } + val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null + return buildString { + append("magnet:?xt=urn:btih:") + append(hash) + stream.behaviorHints.filename + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { filename -> + append("&dn=") + append(encodePathSegment(filename)) + } + stream.sources + .mapNotNull(::trackerUrl) + .distinct() + .forEach { tracker -> + append("&tr=") + append(encodePathSegment(tracker)) + } + } + } + + private fun trackerUrl(source: String): String? { + val value = source.trim() + if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null + return value + .removePrefix("tracker:") + .trim() + .takeIf { it.isNotBlank() } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt index dd73d303..c4880bc1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -1,15 +1,17 @@ package com.nuvio.app.features.debrid +import com.nuvio.app.features.debrid.DebridStreamPresentation.isManagedDebridStream import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamItem class DebridStreamFormatter( private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(), ) { fun format(stream: StreamItem, settings: DebridSettings): StreamItem { - if (!stream.isDirectDebridStream) return stream - val values = buildValues(stream) + if (!stream.isManagedDebridStream) return stream + val values = buildValues(stream, settings) val formattedName = engine.render(settings.streamNameTemplate, values) .lineSequence() .joinToString(" ") { it.trim() } @@ -23,23 +25,26 @@ class DebridStreamFormatter( .trim() return stream.copy( - name = formattedName.ifBlank { stream.name ?: DebridProviders.instantName(stream.clientResolve?.service) }, + name = formattedName.ifBlank { stream.name ?: DebridProviders.displayName(serviceId(stream)) }, description = formattedDescription.ifBlank { stream.description ?: stream.title }, ) } - private fun buildValues(stream: StreamItem): Map { + private fun buildValues(stream: StreamItem, settings: DebridSettings): Map { val resolve = stream.clientResolve val raw = resolve?.stream?.raw val parsed = raw?.parsed + val facts = DebridStreamMetadata.facts( + stream = stream, + preferences = DebridStreamMetadata.effectivePreferences(settings), + ) val seasons = parsed?.seasons.orEmpty() val episodes = parsed?.episodes.orEmpty() val season = resolve?.season ?: seasons.singleOrFirstOrNull() val episode = resolve?.episode ?: episodes.singleOrFirstOrNull() - val visualTags = buildList { - addAll(parsed?.hdr.orEmpty()) - parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) } - } + val visualTags = facts.visualTags.mapNotUnknown { it.label } + val audioTags = facts.audioTags.mapNotUnknown { it.label } + val audioChannels = facts.audioChannels.mapNotUnknown { it.label } val edition = parsed?.edition ?: buildEdition(parsed) return linkedMapOf( @@ -52,46 +57,52 @@ class DebridStreamFormatter( "stream.seasonEpisode" to buildSeasonEpisodeList(season, episode, seasons, episodes), "stream.formattedEpisodes" to formatEpisodes(episodes), "stream.formattedSeasons" to formatSeasons(seasons), - "stream.resolution" to parsed?.resolution, + "stream.resolution" to facts.resolution.labelUnlessUnknown(), "stream.library" to false, - "stream.quality" to parsed?.quality, + "stream.quality" to facts.quality.labelUnlessUnknown(), "stream.visualTags" to visualTags, - "stream.audioTags" to parsed?.audio.orEmpty(), - "stream.audioChannels" to parsed?.channels.orEmpty(), - "stream.languages" to parsed?.languages.orEmpty(), - "stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) }, - "stream.size" to (raw?.size ?: stream.behaviorHints.videoSize)?.let(::DebridTemplateBytes), + "stream.audioTags" to audioTags, + "stream.audioChannels" to audioChannels, + "stream.languages" to languageValues(parsed, facts), + "stream.languageEmojis" to languageValues(parsed, facts).map { languageEmoji(it) }, + "stream.size" to facts.size?.let(::DebridTemplateBytes), "stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes), - "stream.encode" to parsed?.codec?.uppercase(), - "stream.indexer" to (raw?.indexer ?: raw?.tracker), + "stream.encode" to facts.encode.labelUnlessUnknown(), + "stream.indexer" to (raw?.indexer ?: raw?.tracker ?: stream.sourceName), "stream.network" to (parsed?.network ?: raw?.network), - "stream.releaseGroup" to parsed?.group, + "stream.releaseGroup" to facts.releaseGroup.takeIf { it.isNotBlank() }, "stream.duration" to parsed?.duration, "stream.edition" to edition, - "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename), + "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename ?: stream.debridCacheStatus?.cachedName), "stream.regexMatched" to null, - "stream.type" to streamType(resolve), - "service.cached" to resolve?.isCached, - "service.shortName" to serviceShortName(resolve), - "service.name" to serviceName(resolve), - "addon.name" to "Nuvio Direct Debrid", + "stream.type" to streamType(stream, resolve), + "service.cached" to serviceCached(stream, resolve), + "service.shortName" to DebridProviders.shortName(serviceId(stream)), + "service.name" to DebridProviders.displayName(serviceId(stream)), + "addon.name" to stream.addonName, ) } - private fun streamType(resolve: StreamClientResolve?): String = + private fun serviceId(stream: StreamItem): String? = + stream.debridCacheStatus?.providerId ?: stream.clientResolve?.service + + private fun serviceCached(stream: StreamItem, resolve: StreamClientResolve?): Boolean? = + when (stream.debridCacheStatus?.state) { + StreamDebridCacheState.CACHED -> true + StreamDebridCacheState.NOT_CACHED -> false + StreamDebridCacheState.CHECKING, + StreamDebridCacheState.UNKNOWN, + null -> resolve?.isCached + } + + private fun streamType(stream: StreamItem, resolve: StreamClientResolve?): String = when { + stream.debridCacheStatus != null -> "Debrid" resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid" resolve?.type.equals("torrent", ignoreCase = true) -> "p2p" else -> resolve?.type.orEmpty() } - private fun serviceShortName(resolve: StreamClientResolve?): String = - resolve?.serviceExtension?.takeIf { it.isNotBlank() } - ?: DebridProviders.shortName(resolve?.service) - - private fun serviceName(resolve: StreamClientResolve?): String = - DebridProviders.displayName(resolve?.service) - private fun buildEdition(parsed: StreamClientResolveParsed?): String? { if (parsed == null) return null return buildList { @@ -124,6 +135,9 @@ class DebridStreamFormatter( private fun Int.twoDigits(): String = toString().padStart(2, '0') + private fun languageValues(parsed: StreamClientResolveParsed?, facts: DebridStreamFacts): List = + parsed?.languages.orEmpty().ifEmpty { facts.languages.map { it.code } } + private fun languageEmoji(language: String): String = when (language.lowercase()) { "en", "eng", "english" -> "GB" @@ -140,4 +154,16 @@ class DebridStreamFormatter( "multi" -> "Multi" else -> language } + + private inline fun List.mapNotUnknown(label: (T) -> String): List = + map(label).filterNot { it.equals("Unknown", ignoreCase = true) } + + private fun DebridStreamResolution.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamResolution.UNKNOWN } + + private fun DebridStreamQuality.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamQuality.UNKNOWN } + + private fun DebridStreamEncode.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamEncode.UNKNOWN } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt index bb5d25b3..585b020c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -1,8 +1,7 @@ package com.nuvio.app.features.debrid object DebridStreamFormatterDefaults { - const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{stream.resolution::exists[\"\"||\"Direct \"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}Instant" + const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}" const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt index 6647d607..68505de3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt @@ -1,55 +1,126 @@ package com.nuvio.app.features.debrid +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamItem -object DirectDebridStreamFilter { - const val FALLBACK_SOURCE_NAME = "Direct Debrid" +object DebridStreamPresentation { + private val formatter = DebridStreamFormatter() - fun filterInstant(streams: List, settings: DebridSettings? = null): List { - val instantStreams = streams - .filter(::isInstantCandidate) - .map { stream -> - val providerId = stream.clientResolve?.service - val sourceName = DebridProviders.instantName(providerId) - stream.copy( - name = stream.name ?: sourceName, - addonName = sourceName, - addonId = DebridProviders.addonId(providerId), - sourceName = stream.sourceName ?: FALLBACK_SOURCE_NAME, - ) - } - .distinctBy { stream -> - listOf( - stream.clientResolve?.infoHash?.lowercase(), - stream.clientResolve?.fileIdx?.toString(), - stream.clientResolve?.filename, - stream.name, - stream.title, - ).joinToString("|") - } - return if (settings == null) instantStreams else applyPreferences(instantStreams, settings) + fun apply(groups: List, settings: DebridSettings): List { + if (!settings.enabled) return groups + return groups.map { group -> + val debridStreams = group.streams.filter { stream -> stream.isManagedDebridStream } + if (debridStreams.isEmpty()) return@map group + + val presentedDebridStreams = applyPreferences(debridStreams, settings) + .map { stream -> formatter.format(stream, settings) } + val passthroughStreams = group.streams.filterNot { stream -> stream.isManagedDebridStream } + + group.copy(streams = presentedDebridStreams + passthroughStreams) + } } - fun isInstantCandidate(stream: StreamItem): Boolean { - val resolve = stream.clientResolve ?: return false - return resolve.type.equals("debrid", ignoreCase = true) && - DebridProviders.isSupported(resolve.service) && - resolve.isCached == true - } - - fun isDirectDebridSourceName(addonName: String): Boolean = - DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) } - - private fun applyPreferences(streams: List, settings: DebridSettings): List { - val preferences = effectivePreferences(settings) - return streams.map { it to streamFacts(it, preferences) } + internal fun applyPreferences(streams: List, settings: DebridSettings): List { + val preferences = DebridStreamMetadata.effectivePreferences(settings) + return streams.map { it to DebridStreamMetadata.facts(it, preferences) } .filter { (_, facts) -> facts.matchesFilters(preferences) } .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) } .let { sorted -> applyLimits(sorted, preferences) } .map { it.first } } - private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { + internal val StreamItem.isManagedDebridStream: Boolean + get() { + val status = debridCacheStatus + return isDirectDebridStream || ( + isTorrentStream && + status != null && + status.providerId == DebridProviders.TORBOX_ID && + status.state != StreamDebridCacheState.CHECKING + ) + } + + private fun applyLimits( + streams: List>, + preferences: DebridStreamPreferences, + ): List> { + val resolutionCounts = mutableMapOf() + val qualityCounts = mutableMapOf() + val result = mutableListOf>() + for (stream in streams) { + if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break + if (preferences.maxPerResolution > 0) { + val count = resolutionCounts[stream.second.resolution] ?: 0 + if (count >= preferences.maxPerResolution) continue + } + if (preferences.maxPerQuality > 0) { + val count = qualityCounts[stream.second.quality] ?: 0 + if (count >= preferences.maxPerQuality) continue + } + resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 + qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 + result += stream + } + return result + } + + private fun DebridStreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { + if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false + if (resolution in preferences.excludedResolutions) return false + if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false + if (quality in preferences.excludedQualities) return false + if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false + if (visualTags.any { it in preferences.excludedVisualTags }) return false + if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false + if (audioTags.any { it in preferences.excludedAudioTags }) return false + if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false + if (audioChannels.any { it in preferences.excludedAudioChannels }) return false + if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false + if (encode in preferences.excludedEncodes) return false + if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false + if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false + if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false + if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false + return true + } + + private fun compareFacts( + left: DebridStreamFacts, + right: DebridStreamFacts, + criteria: List, + ): Int { + for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { + val comparison = compareKey(left, right, criterion) + if (comparison != 0) return comparison + } + return 0 + } + + private fun compareKey( + left: DebridStreamFacts, + right: DebridStreamFacts, + criterion: DebridStreamSortCriterion, + ): Int { + val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 + return when (criterion.key) { + DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction + DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction + DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction + DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction + DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction + DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction + DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction + DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction + DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) + } + } +} + +internal object DebridStreamMetadata { + fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { val default = DebridStreamPreferences() if (settings.streamPreferences != default) return settings.streamPreferences.normalized() if ( @@ -71,8 +142,12 @@ object DirectDebridStreamFilter { DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), ) - DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) - DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + DebridStreamSortMode.SIZE_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_ASC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC), + ) }, requiredResolutions = DebridStreamResolution.defaultOrder.filter { it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN @@ -126,84 +201,7 @@ object DirectDebridStreamFilter { }.normalized() } - private fun applyLimits( - streams: List>, - preferences: DebridStreamPreferences, - ): List> { - val resolutionCounts = mutableMapOf() - val qualityCounts = mutableMapOf() - val result = mutableListOf>() - for (stream in streams) { - if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break - if (preferences.maxPerResolution > 0) { - val count = resolutionCounts[stream.second.resolution] ?: 0 - if (count >= preferences.maxPerResolution) continue - } - if (preferences.maxPerQuality > 0) { - val count = qualityCounts[stream.second.quality] ?: 0 - if (count >= preferences.maxPerQuality) continue - } - resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 - qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 - result += stream - } - return result - } - - private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { - if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false - if (resolution in preferences.excludedResolutions) return false - if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false - if (quality in preferences.excludedQualities) return false - if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false - if (visualTags.any { it in preferences.excludedVisualTags }) return false - if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false - if (audioTags.any { it in preferences.excludedAudioTags }) return false - if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false - if (audioChannels.any { it in preferences.excludedAudioChannels }) return false - if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false - if (encode in preferences.excludedEncodes) return false - if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false - if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false - if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false - if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false - if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false - if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false - return true - } - - private fun compareFacts( - left: StreamFacts, - right: StreamFacts, - criteria: List, - ): Int { - for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { - val comparison = compareKey(left, right, criterion) - if (comparison != 0) return comparison - } - return 0 - } - - private fun compareKey( - left: StreamFacts, - right: StreamFacts, - criterion: DebridStreamSortCriterion, - ): Int { - val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 - return when (criterion.key) { - DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction - DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction - DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction - DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction - DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction - DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction - DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction - DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction - DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) - } - } - - private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts { + fun facts(stream: StreamItem, preferences: DebridStreamPreferences): DebridStreamFacts { val parsed = stream.clientResolve?.stream?.raw?.parsed val searchText = streamSearchText(stream) val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText) @@ -216,7 +214,7 @@ object DirectDebridStreamFilter { DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) } } val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText) - return StreamFacts( + return DebridStreamFacts( resolution = resolution, quality = quality, visualTags = visualTags, @@ -276,9 +274,9 @@ object DirectDebridStreamFilter { val text = (parsedHdr + searchText).joinToString(" ").lowercase() val tags = mutableListOf() val hasDv = parsedHdr.any { it.isDolbyVisionToken() } || - Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText) + Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|\$)").containsMatchIn(searchText) val hasHdr = parsedHdr.any { it.isHdrToken() } || - Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText) + Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|\$)").containsMatchIn(searchText) if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY @@ -380,7 +378,9 @@ object DirectDebridStreamFilter { } private fun streamSize(stream: StreamItem): Long? = - stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize + stream.clientResolve?.stream?.raw?.size + ?: stream.behaviorHints.videoSize + ?: stream.debridCacheStatus?.cachedSize private fun streamSearchText(stream: StreamItem): String { val resolve = stream.clientResolve @@ -390,6 +390,8 @@ object DirectDebridStreamFilter { stream.name, stream.title, stream.description, + stream.behaviorHints.filename, + stream.debridCacheStatus?.cachedName, resolve?.torrentName, resolve?.filename, raw?.torrentName, @@ -401,25 +403,25 @@ object DirectDebridStreamFilter { parsed?.audio?.joinToString(" "), ).joinToString(" ").lowercase() } - - private fun Int.gigabytes(): Long = this * 1_000_000_000L - - private data class StreamFacts( - val resolution: DebridStreamResolution, - val quality: DebridStreamQuality, - val visualTags: List, - val audioTags: List, - val audioChannels: List, - val encode: DebridStreamEncode, - val languages: List, - val releaseGroup: String, - val size: Long?, - val resolutionRank: Int, - val qualityRank: Int, - val visualRank: Int, - val audioRank: Int, - val channelRank: Int, - val encodeRank: Int, - val languageRank: Int, - ) } + +internal data class DebridStreamFacts( + val resolution: DebridStreamResolution, + val quality: DebridStreamQuality, + val visualTags: List, + val audioTags: List, + val audioChannels: List, + val encode: DebridStreamEncode, + val languages: List, + val releaseGroup: String, + val size: Long?, + val resolutionRank: Int, + val qualityRank: Int, + val visualRank: Int, + val audioRank: Int, + val channelRank: Int, + val encodeRank: Int, + val languageRank: Int, +) + +private fun Int.gigabytes(): Long = this * 1_000_000_000L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt index 23e635e9..f397d841 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt @@ -143,7 +143,8 @@ class DebridStreamTemplateEngine { op.startsWith("join(") -> { val separator = parseArgs(op).firstOrNull() ?: ", " when (value) { - is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(separator) + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } } + .joinToString(separator) else -> valueToText(value) } } @@ -342,7 +343,8 @@ class DebridStreamTemplateEngine { private fun valueToText(value: Any?): String = when (value) { null -> "" - is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(", ") + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } } + .joinToString(", ") is DebridTemplateBytes -> formatBytes(value.value.toDouble()) is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt deleted file mode 100644 index 855e9124..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.nuvio.app.features.debrid - -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -class DirectDebridConfigEncoder { - @OptIn(ExperimentalEncodingApi::class) - fun encode(service: DebridServiceCredential): String { - val servicesJson = """{"service":"${service.provider.id.jsonEscaped()}","apiKey":"${service.apiKey.jsonEscaped()}"}""" - val json = """{"cachedOnly":true,"debridServices":[$servicesJson],"enableTorrent":false}""" - return Base64.Default.encode(json.encodeToByteArray()) - } - - fun encodeTorbox(apiKey: String): String = - encode(DebridServiceCredential(DebridProviders.Torbox, apiKey)) -} - -private fun String.jsonEscaped(): String = buildString { - this@jsonEscaped.forEach { char -> - when (char) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\b' -> append("\\b") - '\u000C' -> append("\\f") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> { - if (char.code < 0x20) { - append("\\u") - append(char.code.toString(16).padStart(4, '0')) - } else { - append(char) - } - } - } - } -} - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt index 6b8e3425..7aff970b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.epochMs import kotlinx.coroutines.CancellationException @@ -16,12 +17,14 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.debrid_missing_api_key +import nuvio.composeapp.generated.resources.debrid_not_cached import nuvio.composeapp.generated.resources.debrid_resolve_failed import nuvio.composeapp.generated.resources.debrid_stream_stale import org.jetbrains.compose.resources.getString object DirectDebridPlaybackResolver { private val torboxResolver = TorboxDirectDebridResolver() + private val torboxAddonStreamResolver = TorboxAddonStreamResolver() private val realDebridResolver = RealDebridDirectDebridResolver() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val mutex = Mutex() @@ -29,7 +32,10 @@ object DirectDebridPlaybackResolver { private val inFlightResolves = mutableMapOf>() suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { - val cacheKey = stream.directDebridResolveCacheKey(season, episode) + if (!shouldResolveToPlayableStream(stream)) { + return DirectDebridResolveResult.Stale + } + val cacheKey = stream.debridResolveCacheKey(season, episode) if (cacheKey == null) { return resolveUncached(stream, season, episode) } @@ -85,7 +91,8 @@ object DirectDebridPlaybackResolver { } suspend fun cachedPlayableStream(stream: StreamItem, season: Int?, episode: Int?): StreamItem? { - val cacheKey = stream.directDebridResolveCacheKey(season, episode) ?: return null + if (!shouldResolveToPlayableStream(stream)) return null + val cacheKey = stream.debridResolveCacheKey(season, episode) ?: return null return getCachedResult(cacheKey) ?.let { result -> stream.withResolvedDebridUrl(result) } } @@ -96,7 +103,7 @@ object DirectDebridPlaybackResolver { private fun getCachedResultLocked(cacheKey: String): DirectDebridResolveResult.Success? { val cached = resolvedCache[cacheKey] ?: return null val age = epochMs() - cached.cachedAtMs - return if (age in 0..DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS) { + return if (age in 0..DEBRID_RESOLVE_CACHE_TTL_MS) { cached.result } else { resolvedCache.remove(cacheKey) @@ -104,11 +111,30 @@ object DirectDebridPlaybackResolver { } } + fun shouldResolveToPlayableStream(stream: StreamItem): Boolean { + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled) return false + if (stream.needsLocalDebridResolve) { + return settings.torboxApiKey.isNotBlank() + } + if (!stream.isDirectDebridStream || stream.playableDirectUrl != null) { + return false + } + return when (DebridProviders.byId(stream.clientResolve?.service)?.id) { + DebridProviders.TORBOX_ID -> settings.torboxApiKey.isNotBlank() + DebridProviders.REAL_DEBRID_ID -> settings.realDebridApiKey.isNotBlank() + else -> false + } + } + private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult = - when (DebridProviders.byId(stream.clientResolve?.service)?.id) { - DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode) - DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode) - else -> DirectDebridResolveResult.Error + when { + stream.needsLocalDebridResolve -> torboxAddonStreamResolver.resolve(stream, season, episode) + else -> when (DebridProviders.byId(stream.clientResolve?.service)?.id) { + DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode) + DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode) + else -> DirectDebridResolveResult.Error + } } suspend fun resolveToPlayableStream( @@ -116,19 +142,20 @@ object DirectDebridPlaybackResolver { season: Int?, episode: Int?, ): DirectDebridPlayableResult { - if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) { + if (!shouldResolveToPlayableStream(stream)) { return DirectDebridPlayableResult.Success(stream) } return when (val result = resolve(stream, season, episode)) { is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result)) DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey + DirectDebridResolveResult.NotCached -> DirectDebridPlayableResult.NotCached DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error } } } -private const val DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L +private const val DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L private data class CachedDirectDebridResolve( val result: DirectDebridResolveResult.Success, @@ -138,6 +165,7 @@ private data class CachedDirectDebridResolve( sealed class DirectDebridPlayableResult { data class Success(val stream: StreamItem) : DirectDebridPlayableResult() data object MissingApiKey : DirectDebridPlayableResult() + data object NotCached : DirectDebridPlayableResult() data object Stale : DirectDebridPlayableResult() data object Error : DirectDebridPlayableResult() } @@ -150,6 +178,7 @@ sealed class DirectDebridResolveResult { ) : DirectDebridResolveResult() data object MissingApiKey : DirectDebridResolveResult() + data object NotCached : DirectDebridResolveResult() data object Stale : DirectDebridResolveResult() data object Error : DirectDebridResolveResult() } @@ -158,10 +187,73 @@ fun DirectDebridPlayableResult.toastMessage(): String? = when (this) { is DirectDebridPlayableResult.Success -> null DirectDebridPlayableResult.MissingApiKey -> runBlocking { getString(Res.string.debrid_missing_api_key) } + DirectDebridPlayableResult.NotCached -> runBlocking { getString(Res.string.debrid_not_cached) } DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) } DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) } } +private class TorboxAddonStreamResolver( + private val fileSelector: TorboxFileSelector = TorboxFileSelector(), +) { + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim() + if (apiKey.isBlank()) { + return DirectDebridResolveResult.MissingApiKey + } + + val hash = stream.infoHash?.trim()?.lowercase() + if (stream.debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED) { + return DirectDebridResolveResult.NotCached + } + if (!hash.isNullOrBlank() && stream.debridCacheStatus?.state != StreamDebridCacheState.CACHED) { + when (TorboxAvailabilityService.isCached(hash)) { + false -> return DirectDebridResolveResult.NotCached + true, null -> Unit + } + } + + val magnet = DebridMagnetBuilder.fromStream(stream) + ?: return DirectDebridResolveResult.Stale + val resolve = stream.toResolveMetadata(season, episode) + + return try { + val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) + val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId() + ?: return create.toFailureForCreate() + + val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId) + if (!torrent.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val files = torrent.body?.data?.files.orEmpty() + val file = fileSelector.selectFile(files, resolve, season, episode) + ?: return DirectDebridResolveResult.Stale + val fileId = file.id ?: return DirectDebridResolveResult.Stale + + val link = TorboxApiClient.requestDownloadLink( + apiKey = apiKey, + torrentId = torrentId, + fileId = fileId, + ) + if (!link.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val url = link.body?.data?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + + DirectDebridResolveResult.Success( + url = url, + filename = file.displayName().takeIf { it.isNotBlank() } + ?: stream.behaviorHints.filename?.takeIf { it.isNotBlank() }, + videoSize = file.size ?: stream.behaviorHints.videoSize, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } +} + private class TorboxDirectDebridResolver( private val fileSelector: TorboxFileSelector = TorboxFileSelector(), ) { @@ -220,11 +312,6 @@ private class TorboxDirectDebridResolver( } } - private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = - when (status) { - 401, 403 -> DirectDebridResolveResult.Error - else -> DirectDebridResolveResult.Stale - } } private class RealDebridDirectDebridResolver( @@ -323,7 +410,8 @@ private fun buildMagnetUri(resolve: StreamClientResolve): String? { append("magnet:?xt=urn:btih:") append(hash) resolve.sources - .filter { it.isNotBlank() } + .mapNotNull { it.toTrackerUrlOrNull() } + .distinct() .forEach { source -> append("&tr=") append(encodePathSegment(source)) @@ -331,8 +419,28 @@ private fun buildMagnetUri(resolve: StreamClientResolve): String? { } } -private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): String? { - val resolve = clientResolve ?: return null +private fun String.toTrackerUrlOrNull(): String? { + val value = trim() + if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null + return value.removePrefix("tracker:").trim().takeIf { it.isNotBlank() } +} + +private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): String? { + val resolve = clientResolve + if (resolve == null && needsLocalDebridResolve) { + val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim().takeIf { it.isNotBlank() } ?: return null + val identity = infoHash ?: torrentMagnetUri ?: behaviorHints.filename ?: return null + return listOf( + DebridProviders.TORBOX_ID, + apiKey.stableFingerprint(), + identity.trim().lowercase(), + fileIdx?.toString().orEmpty(), + behaviorHints.filename.orEmpty().trim().lowercase(), + season?.toString().orEmpty(), + episode?.toString().orEmpty(), + ).joinToString("|") + } + resolve ?: return null val providerId = DebridProviders.byId(resolve.service)?.id ?: return null val apiKey = when (providerId) { DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey @@ -356,6 +464,28 @@ private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): ).joinToString("|") } +private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?): StreamClientResolve = + StreamClientResolve( + type = "torrent", + infoHash = infoHash, + fileIdx = fileIdx, + magnetUri = torrentMagnetUri, + sources = sources, + torrentName = title ?: name, + filename = behaviorHints.filename, + season = season, + episode = episode, + service = DebridProviders.TORBOX_ID, + isCached = debridCacheStatus?.state == StreamDebridCacheState.CACHED, + ) + +private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + 409 -> DirectDebridResolveResult.NotCached + else -> DirectDebridResolveResult.Stale + } + private fun String.stableFingerprint(): String { val hash = fold(1125899906842597L) { acc, char -> (acc * 31L) + char.code } return hash.toULong().toString(16) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt index 61952674..7cd37661 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt @@ -49,7 +49,7 @@ object DirectDebridStreamPreparer { try { when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) { is DirectDebridPlayableResult.Success -> { - if (result.stream.directPlaybackUrl != null) { + if (result.stream.playableDirectUrl != null) { onPrepared(stream, result.stream) } } @@ -71,7 +71,10 @@ object DirectDebridStreamPreparer { ): List { if (limit <= 0) return emptyList() val candidates = streams - .filter { it.isDirectDebridStream && it.directPlaybackUrl == null } + .filter { stream -> + stream.playableDirectUrl == null && + (stream.isDirectDebridStream || stream.isCachedDebridTorrentStream) + } .distinctBy { it.preparationKey() } if (candidates.isEmpty()) return emptyList() @@ -85,7 +88,7 @@ object DirectDebridStreamPreparer { selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, ) - if (autoPlaySelection?.isDirectDebridStream == true) { + if (autoPlaySelection?.let { it.isDirectDebridStream || it.isCachedDebridTorrentStream } == true) { candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() } ?.let(prioritized::add) } @@ -180,7 +183,10 @@ private fun StreamItem.preparationKey(): String { return listOf( addonId.lowercase(), - directPlaybackUrl.orEmpty().lowercase(), + infoHash.orEmpty().lowercase(), + fileIdx?.toString().orEmpty(), + behaviorHints.filename.orEmpty().lowercase(), + playableDirectUrl.orEmpty().lowercase(), name.orEmpty().lowercase(), title.orEmpty().lowercase(), ).joinToString("|") @@ -192,5 +198,5 @@ private fun StreamItem.searchableText(): String = append(name.orEmpty()).append(' ') append(title.orEmpty()).append(' ') append(description.orEmpty()).append(' ') - append(directPlaybackUrl.orEmpty()) + append(playableDirectUrl.orEmpty()) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt deleted file mode 100644 index 6cd5573d..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.nuvio.app.features.debrid - -import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.httpGetText -import com.nuvio.app.features.streams.AddonStreamGroup -import com.nuvio.app.features.streams.StreamParser -import com.nuvio.app.features.streams.epochMs -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -private const val DIRECT_DEBRID_TAG = "DirectDebridStreams" -private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L - -data class DirectDebridStreamTarget( - val provider: DebridProvider, - val apiKey: String, -) { - val addonId: String = DebridProviders.addonId(provider.id) - val addonName: String = DebridProviders.instantName(provider.id) -} - -object DirectDebridStreamSource { - private val log = Logger.withTag(DIRECT_DEBRID_TAG) - private val encoder = DirectDebridConfigEncoder() - private val formatter = DebridStreamFormatter() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val mutex = Mutex() - private val streamCache = mutableMapOf() - private val inFlightFetches = mutableMapOf>() - - fun configuredTargets(): List { - DebridSettingsRepository.ensureLoaded() - val settings = DebridSettingsRepository.snapshot() - if (!settings.enabled || DebridConfig.DIRECT_DEBRID_API_BASE_URL.isBlank()) return emptyList() - return DebridProviders.configuredServices(settings).map { credential -> - DirectDebridStreamTarget( - provider = credential.provider, - apiKey = credential.apiKey, - ) - } - } - - fun sourceNames(): List = - configuredTargets().map { it.addonName } - - fun isEnabled(): Boolean = - sourceNames().isNotEmpty() - - fun placeholders(): List = - configuredTargets().map { target -> - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = true, - ) - } - - fun preloadStreams(type: String, videoId: String) { - if (type.isBlank() || videoId.isBlank()) return - configuredTargets().forEach { target -> - scope.launch { - runCatching { fetchProviderStreams(type, videoId, target) } - } - } - } - - suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult { - val targets = configuredTargets() - if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled - - val results = mutableListOf() - val errors = mutableListOf() - targets.forEach { target -> - val group = fetchProviderStreams(type, videoId, target) - when { - group.streams.isNotEmpty() -> results += group - !group.error.isNullOrBlank() -> errors += group.error - } - } - - return when { - results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results) - errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first()) - else -> DirectDebridStreamFetchResult.Empty - } - } - - suspend fun fetchProviderStreams( - type: String, - videoId: String, - target: DirectDebridStreamTarget, - ): AddonStreamGroup { - val settings = DebridSettingsRepository.snapshot() - val baseUrl = DebridConfig.DIRECT_DEBRID_API_BASE_URL.trim().trimEnd('/') - if (!settings.enabled || baseUrl.isBlank()) { - return target.emptyGroup() - } - - val cacheKey = DirectDebridStreamCacheKey( - providerId = target.provider.id, - type = type.trim().lowercase(), - videoId = videoId.trim(), - baseUrl = baseUrl, - settingsFingerprint = settings.toString(), - ) - cachedGroup(cacheKey)?.let { return it } - - var ownsFetch = false - val newFetch = scope.async(start = CoroutineStart.LAZY) { - fetchProviderStreamsUncached( - baseUrl = baseUrl, - type = type, - videoId = videoId, - target = target, - settings = settings, - ) - } - val activeFetch = mutex.withLock { - cachedGroupLocked(cacheKey)?.let { cached -> - return@withLock null to cached - } - val existing = inFlightFetches[cacheKey] - if (existing != null) { - existing to null - } else { - inFlightFetches[cacheKey] = newFetch - ownsFetch = true - newFetch to null - } - } - activeFetch.second?.let { - newFetch.cancel() - return it - } - val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch") - if (!ownsFetch) newFetch.cancel() - if (ownsFetch) deferred.start() - - return try { - val result = deferred.await() - if (ownsFetch && result.streams.isNotEmpty() && result.error == null) { - mutex.withLock { - streamCache[cacheKey] = CachedDirectDebridStreams( - group = result, - createdAtMs = epochMs(), - ) - } - } - result - } finally { - if (ownsFetch) { - mutex.withLock { - if (inFlightFetches[cacheKey] === deferred) { - inFlightFetches.remove(cacheKey) - } - } - } - } - } - - private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? = - mutex.withLock { cachedGroupLocked(cacheKey) } - - private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? { - val cached = streamCache[cacheKey] ?: return null - val age = epochMs() - cached.createdAtMs - return if (age in 0..STREAM_CACHE_TTL_MS) { - cached.group - } else { - streamCache.remove(cacheKey) - null - } - } - - private suspend fun fetchProviderStreamsUncached( - baseUrl: String, - type: String, - videoId: String, - target: DirectDebridStreamTarget, - settings: DebridSettings, - ): AddonStreamGroup { - val credential = DebridServiceCredential(target.provider, target.apiKey) - val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json" - return try { - val payload = httpGetText(url) - val streams = StreamParser.parse( - payload = payload, - addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME, - addonId = target.addonId, - ) - .let { DirectDebridStreamFilter.filterInstant(it, settings) } - .filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) } - .map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) } - - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = streams, - isLoading = false, - ) - } catch (error: Exception) { - if (error is CancellationException) throw error - log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" } - target.errorGroup(error.message) - } - } - - private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup = - AddonStreamGroup( - addonName = addonName, - addonId = addonId, - streams = emptyList(), - isLoading = false, - ) - - private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup = - AddonStreamGroup( - addonName = addonName, - addonId = addonId, - streams = emptyList(), - isLoading = false, - error = message, - ) -} - -private data class DirectDebridStreamCacheKey( - val providerId: String, - val type: String, - val videoId: String, - val baseUrl: String, - val settingsFingerprint: String, -) - -private data class CachedDirectDebridStreams( - val group: AddonStreamGroup, - val createdAtMs: Long, -) - -sealed class DirectDebridStreamFetchResult { - data object Disabled : DirectDebridStreamFetchResult() - data object Empty : DirectDebridStreamFetchResult() - data class Success(val streams: List) : DirectDebridStreamFetchResult() - data class Error(val message: String) : DirectDebridStreamFetchResult() -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt new file mode 100644 index 00000000..9dbd3876 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt @@ -0,0 +1,111 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamDebridCacheStatus +import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.CancellationException + +object TorboxAvailabilityService { + fun markChecking(groups: List): List { + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled || settings.torboxApiKey.isBlank()) return groups + return groups.updateAvailabilityStatus { stream -> + if (stream.torboxAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) { + stream + } else { + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = StreamDebridCacheState.CHECKING, + ), + ) + } + } + } + + suspend fun annotateCachedAvailability(groups: List): List { + val settings = DebridSettingsRepository.snapshot() + val apiKey = settings.torboxApiKey.trim() + if (!settings.enabled || apiKey.isBlank()) return groups + + val hashes = groups + .flatMap { group -> group.streams.mapNotNull { stream -> stream.torboxAvailabilityHash() } } + .distinct() + if (hashes.isEmpty()) return groups + + val cached = checkCached(apiKey = apiKey, hashes = hashes) + ?: return groups.updateAvailabilityStatus { stream -> + val hash = stream.torboxAvailabilityHash() + if (hash == null) { + stream + } else { + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = StreamDebridCacheState.UNKNOWN, + ), + ) + } + } + + return groups.updateAvailabilityStatus { stream -> + val hash = stream.torboxAvailabilityHash() ?: return@updateAvailabilityStatus stream + val cachedItem = cached[hash] + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = if (cachedItem == null) StreamDebridCacheState.NOT_CACHED else StreamDebridCacheState.CACHED, + cachedName = cachedItem?.name, + cachedSize = cachedItem?.size, + ), + ) + } + } + + suspend fun isCached(hash: String): Boolean? { + val settings = DebridSettingsRepository.snapshot() + val apiKey = settings.torboxApiKey.trim() + val normalizedHash = hash.trim().lowercase().takeIf { it.isNotBlank() } ?: return null + if (!settings.enabled || apiKey.isBlank()) return null + return checkCached(apiKey = apiKey, hashes = listOf(normalizedHash))?.containsKey(normalizedHash) + } + + private suspend fun checkCached( + apiKey: String, + hashes: List, + ): Map? = + try { + val response = TorboxApiClient.checkCached(apiKey = apiKey, hashes = hashes) + if (!response.isSuccessful || response.body?.success == false) { + null + } else { + response.body?.data.orEmpty().mapKeys { it.key.lowercase() } + } + } catch (error: Exception) { + if (error is CancellationException) throw error + null + } +} + +internal fun StreamItem.torboxAvailabilityHash(): String? = + infoHash + ?.trim() + ?.lowercase() + ?.takeIf { needsLocalDebridResolve && it.isNotBlank() } + +private fun List.updateAvailabilityStatus( + transform: (StreamItem) -> StreamItem, +): List = + map { group -> + var changed = false + val updatedStreams = group.streams.map { stream -> + val updated = transform(stream) + if (updated != stream) changed = true + updated + } + if (changed) group.copy(streams = updatedStreams) else group + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 99493f36..d8bfbf27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -62,7 +62,6 @@ import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding -import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.components.DetailActionButtons import com.nuvio.app.features.details.components.CommentDetailSheet import com.nuvio.app.features.details.components.DetailAdditionalInfoSection @@ -373,16 +372,6 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } - val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) { - if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) { - seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id - } else { - meta.id - } - } - LaunchedEffect(meta.type, debridPreloadVideoId) { - DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId) - } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1270,8 +1259,3 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } - -private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean = - hasEpisodes || type.equals("series", ignoreCase = true) || - type.equals("show", ignoreCase = true) || - type.equals("tv", ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index 7ed74677..b0440dc8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -117,7 +117,7 @@ object DownloadsRepository { ): DownloadEnqueueResult { ensureLoaded() - val sourceUrl = stream.directPlaybackUrl + val sourceUrl = stream.playableDirectUrl ?.trim() ?.takeIf { it.isNotBlank() } ?: return DownloadEnqueueResult.MissingUrl diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 255205cd..37940f03 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -57,10 +57,13 @@ 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.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.isSelectableForPlayback import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watching.application.WatchingState @@ -460,6 +463,10 @@ private fun EpisodeStreamsSubView( onDismiss: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val episode = state.selectedEpisode ?: return val streamsUiState = state.streamsUiState @@ -601,6 +608,7 @@ private fun EpisodeStreamsSubView( ) { _, stream -> EpisodeSourceStreamRow( stream = stream, + enabled = stream.isSelectableForPlayback(debridSettings.enabled), onClick = { onStreamSelected(stream, episode) }, ) } @@ -613,6 +621,7 @@ private fun EpisodeStreamsSubView( @Composable private fun EpisodeSourceStreamRow( stream: StreamItem, + enabled: Boolean, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -622,7 +631,7 @@ private fun EpisodeSourceStreamRow( .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(colorScheme.surfaceVariant.copy(alpha = 0.35f)) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 476b0a77..4db2d7b2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.debrid.DirectDebridPlayableResult import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage @@ -854,7 +855,7 @@ fun PlayerScreen( onResolved: (StreamItem) -> Unit, onStale: () -> Unit, ): Boolean { - if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) return false + if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false scope.launch { val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( stream = stream, @@ -896,7 +897,7 @@ fun PlayerScreen( }, ) ) return - val url = stream.directPlaybackUrl ?: return + val url = stream.playableDirectUrl ?: return if (url == activeSourceUrl) return val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) flushWatchProgress() @@ -957,7 +958,7 @@ fun PlayerScreen( }, ) ) return - val url = stream.directPlaybackUrl ?: return + val url = stream.playableDirectUrl ?: return showNextEpisodeCard = false showSourcesPanel = false showEpisodesPanel = false @@ -1165,6 +1166,7 @@ fun PlayerScreen( null }, preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, + debridEnabled = DebridSettingsRepository.snapshot().enabled, ) } else null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index d57dd46d..e5f6b1d9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,9 +48,12 @@ 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.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.i18n.localizedByteUnit +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.isSelectableForPlayback import kotlin.math.round import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -67,6 +71,10 @@ fun PlayerSourcesPanel( modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() AnimatedVisibility( visible = visible, @@ -213,6 +221,7 @@ fun PlayerSourcesPanel( SourceStreamRow( stream = stream, isCurrent = isCurrent, + enabled = stream.isSelectableForPlayback(debridSettings.enabled), onClick = { onStreamSelected(stream) }, ) } @@ -230,6 +239,7 @@ fun PlayerSourcesPanel( private fun SourceStreamRow( stream: StreamItem, isCurrent: Boolean, + enabled: Boolean, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -256,7 +266,7 @@ private fun SourceStreamRow( Modifier }, ) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(14.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -452,9 +462,9 @@ private fun isCurrentStream( currentUrl: String?, currentName: String?, ): Boolean { - if (currentUrl != null && stream.directPlaybackUrl == currentUrl) return true + if (currentUrl != null && stream.playableDirectUrl == currentUrl) return true if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) && - stream.directPlaybackUrl == currentUrl + stream.playableDirectUrl == currentUrl ) return true return false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 013460c3..dffdff27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -5,8 +5,10 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamPresentation import com.nuvio.app.features.debrid.DirectDebridStreamPreparer -import com.nuvio.app.features.debrid.DirectDebridStreamSource +import com.nuvio.app.features.debrid.TorboxAvailabilityService import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId @@ -159,7 +161,6 @@ object PlayerStreamsRepository { val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value - val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.getEnabledScrapersForType(type) @@ -167,7 +168,7 @@ object PlayerStreamsRepository { emptyList() } - if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { + if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, @@ -193,7 +194,7 @@ object PlayerStreamsRepository { ) } - if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { + if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, @@ -216,13 +217,6 @@ object PlayerStreamsRepository { streams = emptyList(), isLoading = true, ) - } + debridTargets.map { target -> - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = true, - ) }, installedAddonOrder) stateFlow.value = StreamsUiState( groups = initialGroups, @@ -291,17 +285,7 @@ object PlayerStreamsRepository { } } - val debridJobs = debridTargets.map { target -> - async { - DirectDebridStreamSource.fetchProviderStreams( - type = type, - videoId = videoId, - target = target, - ) - } - } - - val jobs = addonJobs + pluginJobs + debridJobs + val jobs = addonJobs + pluginJobs val completions = Channel(capacity = Channel.BUFFERED) jobs.forEach { deferred -> launch { @@ -329,25 +313,33 @@ object PlayerStreamsRepository { } else null, ) } - if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { - debridPreparationLaunched = true - launch { - DirectDebridStreamPreparer.prepare( - streams = stateFlow.value.groups.flatMap { it.streams }, - season = season, - episode = episode, - playerSettings = playerSettings, - installedAddonNames = installedAddonNames, - ) { original, prepared -> - stateFlow.update { current -> - current.copy( - groups = DirectDebridStreamPreparer.replacePreparedStream( - groups = current.groups, - original = original, - prepared = prepared, - ), - ) - } + } + if (!debridPreparationLaunched) { + debridPreparationLaunched = true + val checkingGroups = TorboxAvailabilityService.markChecking(stateFlow.value.groups) + stateFlow.update { current -> current.copy(groups = checkingGroups) } + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(stateFlow.value.groups) + val presentedGroups = DebridStreamPresentation.apply( + groups = availabilityGroups, + settings = DebridSettingsRepository.snapshot(), + ) + stateFlow.update { current -> current.copy(groups = presentedGroups) } + launch { + DirectDebridStreamPreparer.prepare( + streams = stateFlow.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + stateFlow.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 30d59534..cbc9eece 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -217,7 +217,7 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = "Max results", - description = "Limit how many Direct Debrid sources appear.", + description = "Limit how many debrid-ready addon streams appear.", value = streamMaxResultsLabel(preferences.maxResults), enabled = settings.enabled, onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, @@ -226,7 +226,7 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = "Sort streams", - description = "Choose how Direct Debrid sources are ordered.", + description = "Choose how debrid-ready addon streams are ordered.", value = sortProfileLabel(preferences.sortCriteria), enabled = settings.enabled, onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 5917325a..f01c2392 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -40,6 +40,7 @@ object StreamAutoPlaySelector { selectedPlugins: Set, preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, + debridEnabled: Boolean = true, ): StreamItem? { if (streams.isEmpty()) return null @@ -62,14 +63,14 @@ object StreamAutoPlaySelector { val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { val bingeGroupMatch = candidateStreams.firstOrNull { stream -> - stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable() + stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable(debridEnabled) } if (bingeGroupMatch != null) return bingeGroupMatch } return when (mode) { StreamAutoPlayMode.MANUAL -> null - StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable() } + StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable(debridEnabled) } StreamAutoPlayMode.REGEX_MATCH -> { val pattern = regexPattern.trim() @@ -89,8 +90,8 @@ object StreamAutoPlaySelector { } else null val matchingStreams = candidateStreams.filter { stream -> - if (!stream.isAutoPlayable()) return@filter false - val url = stream.directPlaybackUrl.orEmpty() + if (!stream.isAutoPlayable(debridEnabled)) return@filter false + val url = stream.playableDirectUrl.orEmpty() val searchableText = buildString { append(stream.addonName).append(' ') @@ -110,11 +111,11 @@ object StreamAutoPlaySelector { } if (matchingStreams.isEmpty()) return null - matchingStreams.firstOrNull { it.isAutoPlayable() } + matchingStreams.firstOrNull { it.isAutoPlayable(debridEnabled) } } } } - private fun StreamItem.isAutoPlayable(): Boolean = - directPlaybackUrl != null || isDirectDebridStream + private fun StreamItem.isAutoPlayable(debridEnabled: Boolean): Boolean = + playableDirectUrl != null || (debridEnabled && (isDirectDebridStream || isCachedDebridTorrentStream)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 0b3d8b24..dd87cc15 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -18,6 +18,7 @@ data class StreamItem( val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, + val debridCacheStatus: StreamDebridCacheStatus? = null, ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } @@ -28,6 +29,14 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + val playableDirectUrl: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { !it.isMagnetLink() } + + val torrentMagnetUri: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { it.isMagnetLink() } + val isDirectDebridStream: Boolean get() = clientResolve?.isDirectDebridCandidate == true @@ -38,6 +47,12 @@ data class StreamItem( externalUrl.isMagnetLink() ) + val isCachedDebridTorrentStream: Boolean + get() = isTorrentStream && debridCacheStatus?.state == StreamDebridCacheState.CACHED + + val needsLocalDebridResolve: Boolean + get() = isTorrentStream && playableDirectUrl == null + val hasPlayableSource: Boolean get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } @@ -45,6 +60,9 @@ data class StreamItem( private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true +fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean = + playableDirectUrl != null || (debridEnabled && (needsLocalDebridResolve || isDirectDebridStream)) + data class StreamBehaviorHints( val bingeGroup: String? = null, val notWebReady: Boolean = false, @@ -59,6 +77,21 @@ data class StreamProxyHeaders( val response: Map? = null, ) +enum class StreamDebridCacheState { + CHECKING, + CACHED, + NOT_CACHED, + UNKNOWN, +} + +data class StreamDebridCacheStatus( + val providerId: String, + val providerName: String, + val state: StreamDebridCacheState, + val cachedName: String? = null, + val cachedSize: Long? = null, +) + data class StreamClientResolve( val type: String? = null, val infoHash: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 2fc87a24..52f8965b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -6,7 +6,9 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.debrid.DirectDebridStreamPreparer -import com.nuvio.app.features.debrid.DirectDebridStreamSource +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamPresentation +import com.nuvio.app.features.debrid.TorboxAvailabilityService import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -101,6 +103,7 @@ object StreamsRepository { PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value + val debridSettings = DebridSettingsRepository.snapshot() val autoPlayMode = playerSettings.streamAutoPlayMode val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL && !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && @@ -134,7 +137,6 @@ object StreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons - val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.getEnabledScrapersForType(type) } else { @@ -145,7 +147,7 @@ object StreamsRepository { groupByRepository = pluginUiState.groupStreamsByRepository, ) - if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { + if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -174,7 +176,7 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -199,13 +201,6 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) - } + debridTargets.map { target -> - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = true, - ) }, installedAddonOrder) _uiState.value = StreamsUiState( requestToken = requestToken, @@ -224,8 +219,7 @@ object StreamsRepository { .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() val totalTasks = streamAddons.size + - pluginProviderGroups.sumOf { it.scrapers.size } + - debridTargets.size + pluginProviderGroups.sumOf { it.scrapers.size } val installedAddonNames = installedAddonOrder.toSet() var autoSelectTriggered = false @@ -255,6 +249,7 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + debridEnabled = debridSettings.enabled, ) _uiState.update { it.copy(autoPlayStream = selected) } if (selected == null) { @@ -363,20 +358,6 @@ object StreamsRepository { } } - debridTargets.forEach { target -> - launch { - publishCompletion( - StreamLoadCompletion.Debrid( - DirectDebridStreamSource.fetchProviderStreams( - type = type, - videoId = videoId, - target = target, - ), - ), - ) - } - } - repeat(totalTasks) { when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { @@ -439,44 +420,36 @@ object StreamsRepository { } } - is StreamLoadCompletion.Debrid -> { - val result = completion.group + } + } + + if (!debridPreparationLaunched) { + debridPreparationLaunched = true + val checkingGroups = TorboxAvailabilityService.markChecking(_uiState.value.groups) + _uiState.update { current -> current.copy(groups = checkingGroups) } + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(_uiState.value.groups) + val presentedGroups = DebridStreamPresentation.apply( + groups = availabilityGroups, + settings = debridSettings, + ) + _uiState.update { current -> current.copy(groups = presentedGroups) } + launch { + DirectDebridStreamPreparer.prepare( + streams = _uiState.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> _uiState.update { current -> - val updated = StreamAutoPlaySelector.orderAddonStreams( - groups = current.groups.map { group -> - if (group.addonId == result.addonId) result else group - }, - installedOrder = installedAddonOrder, - ) - val anyLoading = updated.any { it.isLoading } current.copy( - groups = updated, - isAnyLoading = anyLoading, - emptyStateReason = updated.toEmptyStateReason(anyLoading), + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), ) } - if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { - debridPreparationLaunched = true - launch { - DirectDebridStreamPreparer.prepare( - streams = _uiState.value.groups.flatMap { it.streams }, - season = season, - episode = episode, - playerSettings = playerSettings, - installedAddonNames = installedAddonNames, - ) { original, prepared -> - _uiState.update { current -> - current.copy( - groups = DirectDebridStreamPreparer.replacePreparedStream( - groups = current.groups, - original = original, - prepared = prepared, - ), - ) - } - } - } - } } } } @@ -492,6 +465,7 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + debridEnabled = debridSettings.enabled, ) _uiState.update { it.copy(autoPlayStream = selected) } } @@ -569,7 +543,6 @@ private data class PluginProviderGroup( private sealed interface StreamLoadCompletion { data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion - data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion data class PluginScraper( val addonId: String, val streams: List, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index e7078fd9..8b0cf9d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -85,6 +85,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.launch @@ -130,6 +131,10 @@ fun StreamsScreen( PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val watchProgressUiState by remember { WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState @@ -141,7 +146,6 @@ fun StreamsScreen( val clipboardManager = LocalClipboardManager.current val streamLinkCopiedText = stringResource(Res.string.streams_link_copied) val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) - val torrentUnsupportedText = stringResource(Res.string.streams_torrent_not_supported) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } val storedProgress = if (startFromBeginning) { @@ -216,14 +220,11 @@ fun StreamsScreen( episodeNumber = episodeNumber, episodeTitle = episodeTitle, uiState = uiState, + debridEnabled = debridSettings.enabled, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> - if (stream.isTorrentStream) { - NuvioToastController.show(torrentUnsupportedText) - } else { - onStreamSelected(stream, positionMs, progressFraction) - } + onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) @@ -237,14 +238,11 @@ fun StreamsScreen( episodeNumber = episodeNumber, episodeTitle = episodeTitle, uiState = uiState, + debridEnabled = debridSettings.enabled, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> - if (stream.isTorrentStream) { - NuvioToastController.show(torrentUnsupportedText) - } else { - onStreamSelected(stream, positionMs, progressFraction) - } + onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) @@ -338,7 +336,7 @@ fun StreamsScreen( externalPlayerEnabled = playerSettings.externalPlayerEnabled, onDismiss = { streamActionsTarget = null }, onCopyLink = { stream -> - val directUrl = stream.directPlaybackUrl + val directUrl = stream.playableDirectUrl if (!directUrl.isNullOrBlank()) { clipboardManager.setText(AnnotatedString(directUrl)) NuvioToastController.show(streamLinkCopiedText) @@ -386,6 +384,7 @@ private fun MobileStreamsLayout( episodeNumber: Int?, episodeTitle: String?, uiState: StreamsUiState, + debridEnabled: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -466,6 +465,7 @@ private fun MobileStreamsLayout( StreamList( uiState = uiState, + debridEnabled = debridEnabled, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -759,6 +759,7 @@ private fun FilterChip( @Composable internal fun StreamList( uiState: StreamsUiState, + debridEnabled: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -797,6 +798,7 @@ internal fun StreamList( sectionKey = streamSectionRenderKey(groupIndex = groupIndex, group = group), group = group, showHeader = uiState.selectedFilter == null, + debridEnabled = debridEnabled, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -820,6 +822,7 @@ private fun LazyListScope.streamSection( sectionKey: String, group: AddonStreamGroup, showHeader: Boolean, + debridEnabled: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -863,13 +866,14 @@ private fun LazyListScope.streamSection( ) { _, stream -> StreamCard( stream = stream, + enabled = stream.isSelectableForPlayback(debridEnabled), onClick = { - if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) { + if (stream.isSelectableForPlayback(debridEnabled)) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, onLongClick = { - if (stream.directPlaybackUrl != null) { + if (stream.playableDirectUrl != null) { onStreamLongPress(stream) } }, @@ -966,11 +970,11 @@ private fun StreamSourceHeader( @Composable private fun StreamCard( stream: StreamItem, + enabled: Boolean, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream val cardShape = RoundedCornerShape(12.dp) Row( modifier = modifier @@ -985,7 +989,7 @@ private fun StreamCard( .clip(cardShape) .background(Color.White.copy(alpha = 0.05f)) .combinedClickable( - enabled = isEnabled, + enabled = enabled, onClick = onClick, onLongClick = onLongClick, ) @@ -1019,6 +1023,7 @@ private fun StreamCard( Spacer(modifier = Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + StreamAvailabilityBadge(stream = stream) StreamFileSizeBadge(stream = stream) } } @@ -1123,6 +1128,50 @@ private fun StreamActionsSheet( } } +@Composable +private fun StreamAvailabilityBadge(stream: StreamItem) { + val status = stream.debridCacheStatus ?: return + val (label, background, foreground) = when (status.state) { + StreamDebridCacheState.CHECKING -> Triple( + "${status.providerName} checking", + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), + MaterialTheme.colorScheme.primary, + ) + StreamDebridCacheState.CACHED -> Triple( + "${status.providerName} ready", + Color(0xFF123D2B), + Color(0xFFB8F6D3), + ) + StreamDebridCacheState.NOT_CACHED -> Triple( + "${status.providerName} not cached", + Color(0xFF3D2424), + Color(0xFFFFC9C9), + ) + StreamDebridCacheState.UNKNOWN -> Triple( + "${status.providerName} unknown", + Color(0xFF2C3033), + MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(background) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.2.sp, + ), + color = foreground, + ) + } +} + @Composable private fun StreamFileSizeBadge(stream: StreamItem) { val bytes = stream.behaviorHints.videoSize ?: return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 3f33ce98..9b7ffbda 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -60,6 +60,7 @@ internal fun TabletStreamsLayout( episodeNumber: Int?, episodeTitle: String?, uiState: StreamsUiState, + debridEnabled: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -199,6 +200,7 @@ internal fun TabletStreamsLayout( StreamList( uiState = uiState, + debridEnabled = debridEnabled, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt deleted file mode 100644 index 83b127cc..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.nuvio.app.features.debrid - -import com.nuvio.app.features.streams.StreamParser -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFalse - -class DebridStreamFormatterTest { - private val formatter = DebridStreamFormatter() - - @Test - fun `formats real client stream episode fields and behavior size`() { - val stream = StreamParser.parse( - payload = clientStreamPayload(), - addonName = "Torbox Instant", - addonId = "debrid:torbox", - ).single() - - val formatted = formatter.format( - stream = stream, - settings = DebridSettings( - enabled = true, - torboxApiKey = "key", - streamDescriptionTemplate = CLIENT_TEMPLATE, - ), - ) - - val description = formatted.description.orEmpty() - assertEquals(0, stream.clientResolve?.fileIdx) - assertContains(description, "S05") - assertContains(description, "E02") - assertContains(description, "6.3 GB") - assertFalse(description.contains("6761331156")) - } - - @Test - fun `formats season episode from parsed fields when top level resolve omits them`() { - val stream = StreamParser.parse( - payload = clientStreamPayload(includeTopLevelSeasonEpisode = false), - addonName = "Torbox Instant", - addonId = "debrid:torbox", - ).single() - - val formatted = formatter.format( - stream = stream, - settings = DebridSettings( - enabled = true, - torboxApiKey = "key", - streamDescriptionTemplate = CLIENT_TEMPLATE, - ), - ) - - val description = formatted.description.orEmpty() - assertContains(description, "S05") - assertContains(description, "E02") - assertContains(description, "6.3 GB") - } - - private fun clientStreamPayload(includeTopLevelSeasonEpisode: Boolean = true): String { - val seasonEpisode = if (includeTopLevelSeasonEpisode) { - """ - "season": 5, - "episode": 2, - """.trimIndent() - } else { - "" - } - return """ - { - "streams": [ - { - "name": "TB 2160p cached", - "description": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", - "clientResolve": { - "type": "debrid", - "service": "torbox", - "isCached": true, - "infoHash": "cb7286fb422ed0643037523e7b09446734e9dbc4", - "sources": [], - "fileIdx": "0", - "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", - "title": "The Boys", - "torrentName": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", - $seasonEpisode - "stream": { - "raw": { - "parsed": { - "resolution": "2160p", - "quality": "WEB-DL", - "codec": "hevc", - "audio": ["Atmos", "Dolby Digital Plus"], - "channels": ["5.1"], - "hdr": ["DV", "HDR10+"], - "group": "Kitsune", - "seasons": [5], - "episodes": [2], - "raw_title": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv" - } - } - } - }, - "behaviorHints": { - "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", - "videoSize": 6761331156 - } - } - ] - } - """.trimIndent() - } - - private companion object { - private const val CLIENT_TEMPLATE = - "{stream.title::exists[\"🍿 {stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year}) \"||\"\"]}\n" + - "{stream.season::>=0[\"πŸ‚ S\"||\"\"]}{stream.season::<=9[\"0\"||\"\"]}{stream.season::>0[\"{stream.season} \"||\"\"]}{stream.episode::>=0[\"🎞️ E\"||\"\"]}{stream.episode::<=9[\"0\"||\"\"]}{stream.episode::>0[\"{stream.episode} \"||\"\"]}\n" + - "{stream.quality::exists[\"πŸŽ₯ {stream.quality} \"||\"\"]}{stream.visualTags::exists[\"πŸ“Ί {stream.visualTags::join(' | ')} \"||\"\"]}\n" + - "{stream.audioTags::exists[\"🎧 {stream.audioTags::join(' | ')} \"||\"\"]}{stream.audioChannels::exists[\"πŸ”Š {stream.audioChannels::join(' | ')}\"||\"\"]}\n" + - "{stream.size::>0[\"πŸ“¦ {stream.size::bytes} \"||\"\"]}{stream.encode::exists[\"🎞️ {stream.encode} \"||\"\"]}{stream.indexer::exists[\"πŸ“‘{stream.indexer}\"||\"\"]}\n" + - "{service.cached::istrue[\"⚑Ready \"||\"\"]}{service.cached::isfalse[\"❌ Not Ready \"||\"\"]}{service.shortName::exists[\"({service.shortName}) \"||\"\"]}{stream.type::=Debrid[\"☁️ Debrid \"||\"\"]}πŸ”{addon.name}" - } -} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt new file mode 100644 index 00000000..07877814 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt @@ -0,0 +1,103 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamBehaviorHints +import com.nuvio.app.features.streams.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamDebridCacheStatus +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class DebridStreamPresentationTest { + @Test + fun `formats cached addon torrent streams with custom templates`() { + val stream = localTorboxStream( + filename = "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv", + size = 8_589_934_592, + ) + + val formatted = DebridStreamFormatter().format( + stream = stream, + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamNameTemplate = "{stream.resolution} {service.shortName} {service.cached::istrue[\"Ready\"||\"Not Ready\"]}", + streamDescriptionTemplate = "{stream.quality} {stream.encode}\n{stream.size::bytes}\n{stream.filename}", + ), + ) + + assertEquals("2160p TB Ready", formatted.name) + val description = formatted.description.orEmpty() + assertContains(description, "WEB-DL HEVC") + assertContains(description, "8 GB") + assertContains(description, "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv") + } + + @Test + fun `applies debrid sort filters and limits without removing normal urls`() { + val low = localTorboxStream( + name = "Low", + filename = "Movie.720p.BluRay.x264-GRP.mkv", + size = 4_000_000_000, + ) + val large = localTorboxStream( + name = "Large", + filename = "Movie.2160p.BluRay.REMUX.HEVC-GRP.mkv", + size = 40_000_000_000, + ) + val mid = localTorboxStream( + name = "Mid", + filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv", + size = 10_000_000_000, + ) + val urlStream = StreamItem( + name = "Resolved addon URL", + url = "https://example.test/video.m3u8", + addonName = "Addon", + addonId = "addon:test", + ) + + val group = AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(low, large, mid, urlStream), + ) + val presented = DebridStreamPresentation.apply( + groups = listOf(group), + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamMaxResults = 2, + streamSortMode = DebridStreamSortMode.QUALITY_DESC, + streamMinimumQuality = DebridStreamMinimumQuality.P1080, + streamCodecFilter = DebridStreamCodecFilter.HEVC, + ), + ).single().streams + + assertEquals(listOf("4K TB Ready", "FHD TB Ready", "Resolved addon URL"), presented.map { it.name }) + } + + private fun localTorboxStream( + name: String = "Torrent", + filename: String, + size: Long, + ): StreamItem = + StreamItem( + name = name, + infoHash = "abcdef1234567890abcdef1234567890abcdef12$size".take(40), + addonName = "Addon", + addonId = "addon:test", + behaviorHints = StreamBehaviorHints( + filename = filename, + videoSize = size, + ), + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = StreamDebridCacheState.CACHED, + cachedName = filename, + cachedSize = size, + ), + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt deleted file mode 100644 index 7a670339..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.nuvio.app.features.debrid - -import kotlin.test.Test -import kotlin.test.assertEquals - -class DebridStreamTemplateEngineTest { - private val engine = DebridStreamTemplateEngine() - - @Test - fun `renders nested condition branches and transforms`() { - val rendered = engine.render( - "{stream.resolution::=2160p[\"4K {service.shortName} \"||\"\"]}{stream.title::title}", - mapOf( - "stream.resolution" to "2160p", - "stream.title" to "sample movie", - "service.shortName" to "RD", - ), - ) - - assertEquals("4K RD Sample Movie", rendered) - } - - @Test - fun `formats bytes and joins list values`() { - val rendered = engine.render( - "{stream.size::bytes} {stream.audioTags::join(' | ')}", - mapOf( - "stream.size" to 1_610_612_736L, - "stream.audioTags" to listOf("DTS", "Atmos"), - ), - ) - - assertEquals("1.5 GB DTS | Atmos", rendered) - } - - @Test - fun `renders Debrid size values as readable text while keeping numeric comparisons`() { - val rendered = engine.render( - "{stream.size::>0[\"{stream.size}\"||\"\"]}", - mapOf("stream.size" to DebridTemplateBytes(7_361_184_308L)), - ) - - assertEquals("6.9 GB", rendered) - } -} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt deleted file mode 100644 index 15fcf1e2..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.nuvio.app.features.debrid - -import kotlin.test.Test -import kotlin.test.assertEquals - -class DirectDebridConfigEncoderTest { - @Test - fun `encodes Torbox config exactly like TV`() { - val encoded = DirectDebridConfigEncoder().encodeTorbox("tb_key") - - assertEquals( - "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InRvcmJveCIsImFwaUtleSI6InRiX2tleSJ9XSwiZW5hYmxlVG9ycmVudCI6ZmFsc2V9", - encoded, - ) - } - - @Test - fun `escapes API key before base64 encoding`() { - val encoded = DirectDebridConfigEncoder().encode( - DebridServiceCredential(DebridProviders.RealDebrid, "rd\"key\\line"), - ) - - val expected = "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InJlYWxkZWJyaWQiLCJhcGlLZXkiOiJyZFwia2V5XFxsaW5lIn1dLCJlbmFibGVUb3JyZW50IjpmYWxzZX0=" - assertEquals(expected, encoded) - } -} - diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt deleted file mode 100644 index 593fa6af..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -package com.nuvio.app.features.debrid - -import com.nuvio.app.features.streams.StreamClientResolve -import com.nuvio.app.features.streams.StreamClientResolveParsed -import com.nuvio.app.features.streams.StreamClientResolveRaw -import com.nuvio.app.features.streams.StreamClientResolveStream -import com.nuvio.app.features.streams.StreamItem -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DirectDebridStreamFilterTest { - @Test - fun `keeps only cached supported debrid streams`() { - val torbox = stream(service = DebridProviders.TORBOX_ID, cached = true) - val uncached = stream(service = DebridProviders.TORBOX_ID, cached = false) - val unsupported = stream(service = "other", cached = true) - val torrent = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, type = "torrent") - - val filtered = DirectDebridStreamFilter.filterInstant(listOf(torbox, uncached, unsupported, torrent)) - - assertEquals(1, filtered.size) - assertEquals("Torbox Instant", filtered.single().addonName) - assertEquals("debrid:torbox", filtered.single().addonId) - } - - @Test - fun `dedupes by hash file and filename identity`() { - val first = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "ABC", fileIdx = 2) - val duplicate = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 2) - val otherFile = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 3) - - val filtered = DirectDebridStreamFilter.filterInstant(listOf(first, duplicate, otherFile)) - - assertEquals(2, filtered.size) - } - - @Test - fun `direct debrid stream is not treated as unsupported torrent`() { - val direct = stream(service = DebridProviders.TORBOX_ID, cached = true, infoHash = "hash") - val plainTorrent = StreamItem( - name = "Torrent", - infoHash = "hash", - addonName = "Addon", - addonId = "addon", - ) - - assertTrue(direct.isDirectDebridStream) - assertFalse(direct.isTorrentStream) - assertTrue(plainTorrent.isTorrentStream) - } - - @Test - fun `sorts and limits streams by quality and size`() { - val streams = listOf( - stream(resolution = "1080p", size = 20), - stream(resolution = "2160p", size = 10), - stream(resolution = "2160p", size = 30), - stream(resolution = "720p", size = 40), - ) - - val filtered = DirectDebridStreamFilter.filterInstant( - streams, - DebridSettings( - streamMaxResults = 2, - streamSortMode = DebridStreamSortMode.QUALITY_DESC, - ), - ) - - assertEquals(listOf(30L, 10L), filtered.map { it.clientResolve?.stream?.raw?.size }) - } - - @Test - fun `filters minimum quality dolby vision hdr and codec`() { - val hdrHevc = stream(resolution = "2160p", hdr = listOf("HDR10"), codec = "HEVC", size = 10) - val dvHevc = stream(resolution = "2160p", hdr = listOf("DV", "HDR10"), codec = "HEVC", size = 20) - val sdrAvc = stream(resolution = "1080p", codec = "AVC", size = 30) - val hdHevc = stream(resolution = "720p", codec = "HEVC", size = 40) - - val noDvHdrHevc4k = DirectDebridStreamFilter.filterInstant( - listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), - DebridSettings( - streamMinimumQuality = DebridStreamMinimumQuality.P2160, - streamDolbyVisionFilter = DebridStreamFeatureFilter.EXCLUDE, - streamHdrFilter = DebridStreamFeatureFilter.ONLY, - streamCodecFilter = DebridStreamCodecFilter.HEVC, - ), - ) - - assertEquals(listOf(10L), noDvHdrHevc4k.map { it.clientResolve?.stream?.raw?.size }) - - val dvOnly = DirectDebridStreamFilter.filterInstant( - listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), - DebridSettings(streamDolbyVisionFilter = DebridStreamFeatureFilter.ONLY), - ) - - assertEquals(listOf(20L), dvOnly.map { it.clientResolve?.stream?.raw?.size }) - } - - @Test - fun `applies stream preference filters and sort criteria`() { - val remuxAtmos = stream( - resolution = "2160p", - quality = "BluRay REMUX", - codec = "HEVC", - audio = listOf("Atmos", "TrueHD"), - channels = listOf("7.1"), - languages = listOf("en"), - group = "GOOD", - size = 40_000_000_000, - ) - val webAac = stream( - resolution = "2160p", - quality = "WEB-DL", - codec = "AVC", - audio = listOf("AAC"), - channels = listOf("2.0"), - languages = listOf("en"), - group = "NOPE", - size = 4_000_000_000, - ) - val blurayDts = stream( - resolution = "1080p", - quality = "BluRay", - codec = "AVC", - audio = listOf("DTS"), - channels = listOf("5.1"), - languages = listOf("hi"), - group = "GOOD", - size = 12_000_000_000, - ) - - val filtered = DirectDebridStreamFilter.filterInstant( - listOf(webAac, blurayDts, remuxAtmos), - DebridSettings( - streamPreferences = DebridStreamPreferences( - maxResults = 2, - maxPerResolution = 1, - sizeMinGb = 5, - requiredResolutions = listOf(DebridStreamResolution.P2160, DebridStreamResolution.P1080), - excludedQualities = listOf(DebridStreamQuality.WEB_DL), - requiredAudioChannels = listOf(DebridStreamAudioChannel.CH_7_1, DebridStreamAudioChannel.CH_5_1), - excludedEncodes = listOf(DebridStreamEncode.UNKNOWN), - excludedLanguages = listOf(DebridStreamLanguage.IT), - requiredReleaseGroups = listOf("GOOD"), - sortCriteria = listOf( - DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), - DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC), - ), - ), - ), - ) - - assertEquals(listOf(40_000_000_000L, 12_000_000_000L), filtered.map { it.clientResolve?.stream?.raw?.size }) - } - - private fun stream( - service: String? = DebridProviders.TORBOX_ID, - cached: Boolean? = true, - type: String = "debrid", - infoHash: String = "hash", - fileIdx: Int = 1, - resolution: String? = null, - quality: String? = null, - hdr: List = emptyList(), - codec: String? = null, - audio: List = emptyList(), - channels: List = emptyList(), - languages: List = emptyList(), - group: String? = null, - size: Long? = null, - ): StreamItem = - StreamItem( - name = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", - description = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", - addonName = "Direct Debrid", - addonId = "debrid", - clientResolve = StreamClientResolve( - type = type, - service = service, - isCached = cached, - infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(), - fileIdx = fileIdx, - filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", - torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", - stream = StreamClientResolveStream( - raw = StreamClientResolveRaw( - torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", - filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", - size = size, - folderSize = size, - parsed = StreamClientResolveParsed( - resolution = resolution, - quality = quality, - hdr = hdr, - codec = codec, - audio = audio, - channels = channels, - languages = languages, - group = group, - ), - ), - ), - ), - ) -} - -private fun Long?.orEmptyHashPart(): String = - this?.toString().orEmpty() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index 9260e883..41519dac 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -158,7 +158,7 @@ class StreamParserTest { ] } """.trimIndent(), - addonName = "Direct Debrid", + addonName = "Debrid Fixture", addonId = "debrid:torbox", ) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 10b019f6..48de7ef6 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=64 -MARKETING_VERSION=0.1.22 +MARKETING_VERSION=0.1.0