fix: route torrent:// urls through torrent playback instead of the player

Addons that embed the torrent identity in the url field (torrent://<hash>[/<fileIdx>]) with a null infoHash were treated as direct HTTP streams: playableDirectUrl surfaced the raw torrent:// string to the player, isTorrentStream missed it so debrid never engaged, and p2pInfoHash could not extract a hash so the p2p engine never engaged. Equivalent of NuvioTV#2174: classify torrent:// URLs as torrents, never surface them as playable, extract a validated info hash (40-char hex or 32-char base32) and trailing file index for the p2p path, and build proper magnet URIs for debrid from the effective hash. torrent://null and other unextractable URLs yield a null hash and stay unselectable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aniket Tuli 2026-06-12 02:38:50 -07:00
parent 4eb919df71
commit a211d77017
6 changed files with 394 additions and 12 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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<String>
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()

View file

@ -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",
)
}

View file

@ -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",
)
}