mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-09 00:17:59 +00:00
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:
parent
4eb919df71
commit
a211d77017
6 changed files with 394 additions and 12 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue