diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 458c752c5..cbb98eedd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1815,7 +1815,7 @@ private fun MainAppContent( replaceStreamRoute: Boolean, ) { val infoHash = stream.p2pInfoHash ?: return - val sentinelUrl = p2pSentinelUrl(infoHash, stream.fileIdx) + val sentinelUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( type = launch.type, @@ -1835,7 +1835,7 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, infoHash = infoHash, - fileIdx = stream.fileIdx, + fileIdx = stream.p2pFileIdx, sources = stream.sources, bingeGroup = stream.behaviorHints.bingeGroup, ) @@ -1863,7 +1863,7 @@ private fun MainAppContent( parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaType = launch.parentMetaType ?: launch.type, torrentInfoHash = infoHash, - torrentFileIdx = stream.fileIdx, + torrentFileIdx = stream.p2pFileIdx, torrentFilename = stream.behaviorHints.filename, torrentTrackers = stream.p2pTrackers, initialPositionMs = resolvedResumePositionMs ?: 0L, 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 index e45d32bbf..11e95c0c4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt @@ -5,7 +5,7 @@ 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 + val hash = stream.p2pInfoHash ?: return null return buildString { append("magnet:?xt=urn:btih:") append(hash) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index 12b82dc3b..fa8c9e442 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -51,7 +51,7 @@ internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean = internal fun StreamItem.playerSourceIdentityKey(): String? { p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> - return "torrent:$hash:${fileIdx ?: -1}" + return "torrent:$hash:${p2pFileIdx ?: -1}" } clientResolve?.let { resolve -> @@ -138,7 +138,7 @@ internal fun PlayerScreenRuntime.saveP2pStreamForReuse( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, infoHash = infoHash, - fileIdx = stream.fileIdx, + fileIdx = stream.p2pFileIdx, sources = stream.sources, bingeGroup = stream.behaviorHints.bingeGroup, ) @@ -160,12 +160,12 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) { season = activeSeasonNumber, episode = activeEpisodeNumber, ) - activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx) + activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() activeTorrentInfoHash = infoHash - activeTorrentFileIdx = stream.fileIdx + activeTorrentFileIdx = stream.p2pFileIdx activeTorrentFilename = stream.behaviorHints.filename activeTorrentTrackers = stream.p2pTrackers activeSourceIdentityKey = stream.playerSourceIdentityKey() @@ -202,12 +202,12 @@ internal fun PlayerScreenRuntime.switchToP2pEpisodeStream( season = episode.season, episode = episode.episode, ) - activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx) + activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() activeTorrentInfoHash = infoHash - activeTorrentFileIdx = stream.fileIdx + activeTorrentFileIdx = stream.p2pFileIdx activeTorrentFilename = stream.behaviorHints.filename activeTorrentTrackers = stream.p2pTrackers applyEpisodeStreamMetadata(stream, episode, resume) 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 4719a89db..4d3b4aab9 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 @@ -32,14 +32,23 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + /** + * First URL that can be handed directly to a player or HTTP consumer. + * `magnet:` and `torrent://` URLs are filtered out, falling back to + * [externalUrl] when [url] carries one of those schemes. + */ val playableDirectUrl: String? get() = listOfNotNull(url, externalUrl) - .firstOrNull { !it.isMagnetLink() } + .firstOrNull { !it.isMagnetLink() && !it.isTorrentSchemeUrl() } val torrentMagnetUri: String? get() = listOfNotNull(url, externalUrl) .firstOrNull { it.isMagnetLink() } + val torrentSchemeUri: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { it.isTorrentSchemeUrl() } + val isDirectDebridStream: Boolean get() = clientResolve?.isDirectDebridCandidate == true @@ -50,7 +59,9 @@ data class StreamItem( get() = !isDirectDebridStream && ( !infoHash.isNullOrBlank() || url.isMagnetLink() || - externalUrl.isMagnetLink() + externalUrl.isMagnetLink() || + url.isTorrentSchemeUrl() || + externalUrl.isTorrentSchemeUrl() ) val isCachedDebridTorrentStream: Boolean @@ -63,6 +74,10 @@ data class StreamItem( get() = infoHash.normalizedInfoHash() ?: clientResolve?.infoHash.normalizedInfoHash() ?: torrentMagnetUri.extractBtihInfoHash() + ?: torrentSchemeUri.extractTorrentSchemeInfoHash() + + val p2pFileIdx: Int? + get() = fileIdx ?: torrentSchemeUri.extractTorrentSchemeFileIdx() val p2pTrackers: List get() = sources @@ -92,6 +107,32 @@ data class StreamBadge( private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true +private fun String?.isTorrentSchemeUrl(): Boolean = + this?.trimStart()?.startsWith("torrent://", ignoreCase = true) == true + +private fun String?.extractTorrentSchemeInfoHash(): String? { + val raw = this?.trimStart()?.takeIf { it.isTorrentSchemeUrl() } ?: return null + return raw.removeRange(0, "torrent://".length) + .substringBefore('/') + .substringBefore('?') + .trim() + .takeIf { it.isValidInfoHash() } +} + +private fun String?.extractTorrentSchemeFileIdx(): Int? { + val raw = this?.trimStart()?.takeIf { it.isTorrentSchemeUrl() } ?: return null + val path = raw.removeRange(0, "torrent://".length).substringBefore('?') + if ('/' !in path) return null + return path.substringAfter('/') + .trim() + .takeIf { segment -> segment.isNotEmpty() && segment.all { it.isDigit() } } + ?.toIntOrNull() +} + +private fun String.isValidInfoHash(): Boolean = + (length == 40 && all { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' }) || + (length == 32 && all { it in '2'..'7' || it.lowercaseChar() in 'a'..'z' }) + private fun String?.normalizedInfoHash(): String? = this ?.trim() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt new file mode 100644 index 000000000..b772f791a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt @@ -0,0 +1,52 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DebridMagnetBuilderTest { + + private val hexHash = "0123456789abcdef0123456789abcdef01234567" + + @Test + fun `builds well-formed magnet from torrent scheme url`() { + val stream = stream(url = "torrent://$hexHash") + assertEquals("magnet:?xt=urn:btih:$hexHash", DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `builds magnet from dedicated infoHash field`() { + val stream = stream(infoHash = hexHash) + assertEquals("magnet:?xt=urn:btih:$hexHash", DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `passes existing magnet url through unchanged`() { + val magnet = "magnet:?xt=urn:btih:$hexHash&dn=Test" + val stream = stream(url = magnet) + assertEquals(magnet, DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `returns null for torrent-null sentinel url`() { + val stream = stream(url = "torrent://null") + assertNull(DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `returns null for plain http stream`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertNull(DebridMagnetBuilder.fromStream(stream)) + } + + private fun stream( + url: String? = null, + infoHash: String? = null, + ): StreamItem = StreamItem( + url = url, + infoHash = infoHash, + addonName = "TestAddon", + addonId = "test.addon", + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt new file mode 100644 index 000000000..d0241a33a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt @@ -0,0 +1,289 @@ +package com.nuvio.app.features.streams + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StreamModelsTest { + + private val hexHash = "0123456789abcdef0123456789abcdef01234567" + private val base32Hash = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + // ----------------------------------------------------------------------- + // isTorrentStream + // ----------------------------------------------------------------------- + + @Test + fun `torrent scheme url in url field is detected as torrent stream`() { + val stream = stream(url = "torrent://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url with fileIdx path is detected as torrent stream`() { + val stream = stream(url = "torrent://$hexHash/0") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url is detected case-insensitively`() { + val stream = stream(url = "TORRENT://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url in externalUrl is detected as torrent stream`() { + val stream = stream(externalUrl = "torrent://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url with leading whitespace is detected as torrent stream`() { + val stream = stream(url = " torrent://$hexHash") + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + } + + @Test + fun `magnet url with leading whitespace is detected as torrent stream`() { + val stream = stream(url = "\nmagnet:?xt=urn:btih:$hexHash") + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + } + + @Test + fun `torrent url sentinel torrent-null-string is still detected as torrent scheme`() { + val stream = stream(url = "torrent://null") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `plain http url is not a torrent stream`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertFalse(stream.isTorrentStream) + } + + @Test + fun `plain http url with torrent in path is not a torrent stream`() { + val stream = stream(url = "https://cdn.example.com/torrent/download.mp4") + assertFalse(stream.isTorrentStream) + } + + // ----------------------------------------------------------------------- + // playableDirectUrl — torrent:// and magnet: are never surfaced + // ----------------------------------------------------------------------- + + @Test + fun `torrent scheme url yields null playableDirectUrl`() { + val stream = stream(url = "torrent://$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `torrent scheme url in externalUrl yields null playableDirectUrl`() { + val stream = stream(externalUrl = "torrent://$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `magnet url yields null playableDirectUrl`() { + val stream = stream(url = "magnet:?xt=urn:btih:$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `plain http url is surfaced as playableDirectUrl`() { + val url = "https://cdn.example.com/video.mp4" + val stream = stream(url = url) + assertEquals(url, stream.playableDirectUrl) + } + + @Test + fun `torrent url falls through to http externalUrl`() { + val httpUrl = "https://cdn.example.com/video.mp4" + val stream = stream( + url = "torrent://$hexHash", + externalUrl = httpUrl, + ) + assertEquals(httpUrl, stream.playableDirectUrl) + } + + // ----------------------------------------------------------------------- + // p2pInfoHash extraction + // ----------------------------------------------------------------------- + + @Test + fun `p2pInfoHash extracts hex hash from torrent scheme url`() { + val stream = stream(url = "torrent://$hexHash") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts base32 hash from torrent scheme url`() { + val stream = stream(url = "torrent://$base32Hash") + assertEquals(base32Hash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts uppercase hex hash from uppercase scheme`() { + val upperHash = hexHash.uppercase() + val stream = stream(url = "TORRENT://$upperHash") + assertEquals(upperHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash stops at fileIdx path segment`() { + val stream = stream(url = "torrent://$hexHash/3") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash stops at query separator`() { + val stream = stream(url = "torrent://$hexHash?index=2") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts hex hash from magnet url`() { + val stream = stream(url = "magnet:?xt=urn:btih:$hexHash&dn=Test") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts base32 hash from magnet url`() { + val stream = stream(url = "magnet:?xt=urn:btih:$base32Hash&dn=Test") + assertEquals(base32Hash, stream.p2pInfoHash) + } + + @Test + fun `dedicated infoHash field wins over different hash in torrent url`() { + val dedicated = "fedcba9876543210fedcba9876543210fedcba98" + val stream = stream( + url = "torrent://$hexHash", + infoHash = dedicated, + ) + assertEquals(dedicated, stream.p2pInfoHash) + } + + @Test + fun `torrent-null sentinel yields null p2pInfoHash`() { + val stream = stream(url = "torrent://null") + assertNull(stream.p2pInfoHash) + } + + @Test + fun `torrent url with invalid hash length yields null p2pInfoHash`() { + val stream = stream(url = "torrent://abcdef123456") + assertNull(stream.p2pInfoHash) + } + + @Test + fun `plain http url yields null p2pInfoHash`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertNull(stream.p2pInfoHash) + } + + // ----------------------------------------------------------------------- + // p2pFileIdx extraction + // ----------------------------------------------------------------------- + + @Test + fun `p2pFileIdx extracts trailing index segment from torrent url`() { + val stream = stream(url = "torrent://$hexHash/3") + assertEquals(3, stream.p2pFileIdx) + } + + @Test + fun `dedicated fileIdx field wins over torrent url segment`() { + val stream = stream(url = "torrent://$hexHash/3", fileIdx = 7) + assertEquals(7, stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx is null without a path segment`() { + val stream = stream(url = "torrent://$hexHash") + assertNull(stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx is null for non-numeric path segment`() { + val stream = stream(url = "torrent://$hexHash/name.mkv") + assertNull(stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx ignores query parameters`() { + val stream = stream(url = "torrent://$hexHash/2?foo=bar") + assertEquals(2, stream.p2pFileIdx) + } + + // ----------------------------------------------------------------------- + // Parser integration — torrent:// in JSON url field + // ----------------------------------------------------------------------- + + @Test + fun `parser preserves torrent scheme url and stream is correctly classified`() { + val streams = StreamParser.parse( + payload = """ + { + "streams": [ + { + "url": "torrent://$hexHash", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + val stream = streams.single() + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `parser keeps normal http stream playable and not torrent`() { + val streams = StreamParser.parse( + payload = """ + { + "streams": [ + { + "url": "https://cdn.example.com/video.mp4", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + val stream = streams.single() + assertFalse(stream.isTorrentStream) + assertEquals("https://cdn.example.com/video.mp4", stream.playableDirectUrl) + assertNull(stream.p2pInfoHash) + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private fun stream( + url: String? = null, + infoHash: String? = null, + fileIdx: Int? = null, + externalUrl: String? = null, + ): StreamItem = StreamItem( + url = url, + infoHash = infoHash, + fileIdx = fileIdx, + externalUrl = externalUrl, + addonName = "TestAddon", + addonId = "test.addon", + ) +}