Merge branch 'toropt' into cmp-rewrite

This commit is contained in:
tapframe 2026-06-04 20:26:46 +05:30
commit dcf4d059f6
17 changed files with 1031 additions and 212 deletions

View file

@ -352,6 +352,14 @@ android {
manifest.srcFile("src/androidFull/AndroidManifest.xml")
java.srcDir(fullCommonSourceDir)
}
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
isUniversalApk = false
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"

View file

@ -1697,7 +1697,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,
@ -1714,11 +1714,12 @@ private fun MainAppContent(
addonId = stream.addonId,
requestHeaders = emptyMap(),
responseHeaders = emptyMap(),
filename = stream.behaviorHints.filename,
filename = stream.p2pFilename,
videoSize = stream.behaviorHints.videoSize,
infoHash = infoHash,
fileIdx = stream.fileIdx,
sources = stream.sources,
fileIdx = stream.p2pFileIdx,
magnetUri = stream.torrentMagnetUri,
sources = stream.p2pSourceHints,
bingeGroup = stream.behaviorHints.bingeGroup,
)
}
@ -1745,8 +1746,9 @@ private fun MainAppContent(
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
torrentInfoHash = infoHash,
torrentFileIdx = stream.fileIdx,
torrentFilename = stream.behaviorHints.filename,
torrentFileIdx = stream.p2pFileIdx,
torrentFilename = stream.p2pFilename,
torrentMagnetUri = stream.torrentMagnetUri,
torrentTrackers = stream.p2pTrackers,
initialPositionMs = resolvedResumePositionMs ?: 0L,
initialProgressFraction = resolvedResumeProgressFraction,
@ -1814,10 +1816,10 @@ private fun MainAppContent(
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) {
if (cached.url.isBlank() && !cached.infoHash.isNullOrBlank()) {
if (cached.url.isBlank() && (!cached.infoHash.isNullOrBlank() || !cached.magnetUri.isNullOrBlank())) {
val cachedStream = StreamItem(
name = cached.streamName,
url = null,
url = cached.magnetUri,
infoHash = cached.infoHash,
fileIdx = cached.fileIdx,
sources = cached.sources,
@ -2329,6 +2331,7 @@ private fun MainAppContent(
torrentInfoHash = launch.torrentInfoHash,
torrentFileIdx = launch.torrentFileIdx,
torrentFilename = launch.torrentFilename,
torrentMagnetUri = launch.torrentMagnetUri,
torrentTrackers = launch.torrentTrackers,
initialPositionMs = launch.initialPositionMs,
initialProgressFraction = launch.initialProgressFraction,

View file

@ -45,6 +45,9 @@ object P2pSettingsRepository {
if (p2pEnabled == enabled) return
p2pEnabled = enabled
P2pSettingsStorage.saveP2pEnabled(enabled)
if (!enabled) {
P2pStreamingEngine.shutdown()
}
publish()
}
@ -94,6 +97,7 @@ data class P2pStreamRequest(
val infoHash: String,
val fileIdx: Int?,
val filename: String? = null,
val magnetUri: String? = null,
val trackers: List<String> = emptyList(),
)
@ -119,6 +123,8 @@ class P2pStreamingException(message: String) : Exception(message)
expect object P2pStreamingEngine {
val state: StateFlow<P2pStreamingState>
fun warmup()
fun cooldownWarmup()
suspend fun startStream(request: P2pStreamRequest): String
fun stopStream()
fun shutdown()

View file

@ -45,6 +45,7 @@ data class PlayerLaunch(
val torrentInfoHash: String? = null,
val torrentFileIdx: Int? = null,
val torrentFilename: String? = null,
val torrentMagnetUri: String? = null,
val torrentTrackers: List<String> = emptyList(),
val initialPositionMs: Long = 0L,
val initialProgressFraction: Float? = null,

View file

@ -178,6 +178,7 @@ fun PlayerScreen(
torrentInfoHash: String? = null,
torrentFileIdx: Int? = null,
torrentFilename: String? = null,
torrentMagnetUri: String? = null,
torrentTrackers: List<String> = emptyList(),
initialPositionMs: Long = 0L,
initialProgressFraction: Float? = null,
@ -248,6 +249,7 @@ fun PlayerScreen(
var activeTorrentInfoHash by rememberSaveable { mutableStateOf(torrentInfoHash) }
var activeTorrentFileIdx by rememberSaveable { mutableStateOf(torrentFileIdx) }
var activeTorrentFilename by rememberSaveable { mutableStateOf(torrentFilename) }
var activeTorrentMagnetUri by rememberSaveable { mutableStateOf(torrentMagnetUri) }
var activeTorrentTrackers by remember { mutableStateOf(torrentTrackers) }
var p2pResolvedSourceUrl by remember { mutableStateOf<String?>(null) }
val activePlaybackIdentity = activeTorrentInfoHash
@ -1203,6 +1205,7 @@ fun PlayerScreen(
activeTorrentInfoHash = null
activeTorrentFileIdx = null
activeTorrentFilename = null
activeTorrentMagnetUri = null
activeTorrentTrackers = emptyList()
p2pResolvedSourceUrl = null
}
@ -1230,11 +1233,12 @@ fun PlayerScreen(
addonId = stream.addonId,
requestHeaders = emptyMap(),
responseHeaders = emptyMap(),
filename = stream.behaviorHints.filename,
filename = stream.p2pFilename,
videoSize = stream.behaviorHints.videoSize,
infoHash = infoHash,
fileIdx = stream.fileIdx,
sources = stream.sources,
fileIdx = stream.p2pFileIdx,
magnetUri = stream.torrentMagnetUri,
sources = stream.p2pSourceHints,
bingeGroup = stream.behaviorHints.bingeGroup,
)
}
@ -1255,13 +1259,14 @@ fun PlayerScreen(
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
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentFileIdx = stream.p2pFileIdx
activeTorrentFilename = stream.p2pFilename
activeTorrentMagnetUri = stream.torrentMagnetUri
activeTorrentTrackers = stream.p2pTrackers
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
@ -1313,13 +1318,14 @@ fun PlayerScreen(
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
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentFileIdx = stream.p2pFileIdx
activeTorrentFilename = stream.p2pFilename
activeTorrentMagnetUri = stream.torrentMagnetUri
activeTorrentTrackers = stream.p2pTrackers
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
@ -1938,6 +1944,7 @@ fun PlayerScreen(
activeTorrentInfoHash,
activeTorrentFileIdx,
activeTorrentFilename,
activeTorrentMagnetUri,
activeTorrentTrackers,
p2pSettingsUiState.p2pEnabled,
) {
@ -1954,6 +1961,7 @@ fun PlayerScreen(
p2pResolvedSourceUrl = null
val requestedFileIdx = activeTorrentFileIdx
val requestedFilename = activeTorrentFilename
val requestedMagnetUri = activeTorrentMagnetUri
val requestedTrackers = activeTorrentTrackers
errorMessage = null
playerController = null
@ -1967,6 +1975,7 @@ fun PlayerScreen(
infoHash = infoHash,
fileIdx = requestedFileIdx,
filename = requestedFilename,
magnetUri = requestedMagnetUri,
trackers = requestedTrackers,
),
)

View file

@ -16,6 +16,7 @@ data class CachedStreamLink(
val videoSize: Long? = null,
val infoHash: String? = null,
val fileIdx: Int? = null,
val magnetUri: String? = null,
val sources: List<String> = emptyList(),
val bingeGroup: String? = null,
)
@ -52,6 +53,7 @@ object StreamLinkCacheRepository {
videoSize: Long? = null,
infoHash: String? = null,
fileIdx: Int? = null,
magnetUri: String? = null,
sources: List<String> = emptyList(),
bingeGroup: String? = null,
) {
@ -67,6 +69,7 @@ object StreamLinkCacheRepository {
videoSize = videoSize,
infoHash = infoHash,
fileIdx = fileIdx,
magnetUri = magnetUri,
sources = sources,
bingeGroup = bingeGroup,
)
@ -92,7 +95,7 @@ object StreamLinkCacheRepository {
StreamLinkCacheStorage.removeEntry(hashedKey(contentKey))
return null
}
if (entry.url.isBlank() && entry.infoHash.isNullOrBlank()) {
if (entry.url.isBlank() && entry.infoHash.isNullOrBlank() && entry.magnetUri.isNullOrBlank()) {
StreamLinkCacheStorage.removeEntry(hashedKey(contentKey))
return null
}

View file

@ -36,7 +36,7 @@ data class StreamItem(
.firstOrNull { !it.isMagnetLink() }
val torrentMagnetUri: String?
get() = listOfNotNull(url, externalUrl)
get() = listOfNotNull(url, externalUrl, clientResolve?.magnetUri)
.firstOrNull { it.isMagnetLink() }
val isDirectDebridStream: Boolean
@ -61,10 +61,17 @@ data class StreamItem(
val p2pInfoHash: String?
get() = infoHash.normalizedInfoHash()
?: clientResolve?.infoHash.normalizedInfoHash()
?: clientResolve?.magnetUri.extractBtihInfoHash()
?: torrentMagnetUri.extractBtihInfoHash()
val p2pFileIdx: Int?
get() = fileIdx ?: clientResolve?.fileIdx
val p2pFilename: String?
get() = behaviorHints.filename ?: clientResolve?.filename
val p2pTrackers: List<String>
get() = sources
get() = p2pSourceHints
.asSequence()
.filter { it.startsWith("tracker:") }
.map { it.removePrefix("tracker:").trim() }
@ -72,6 +79,10 @@ data class StreamItem(
.distinct()
.toList()
val p2pSourceHints: List<String>
get() = (sources + clientResolve?.sources.orEmpty())
.distinct()
val isAddonDebridCandidate: Boolean
get() = isInstalledAddonStream && (needsLocalDebridResolve || isDirectDebridStream)

View file

@ -53,6 +53,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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -91,6 +92,8 @@ import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.p2p.P2pStreamingEngine
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.launch
@ -140,6 +143,10 @@ fun StreamsScreen(
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val p2pSettings by remember {
P2pSettingsRepository.ensureLoaded()
P2pSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
@ -181,6 +188,15 @@ fun StreamsScreen(
}
}
DisposableEffect(P2pSettingsRepository.isVisible, p2pSettings.p2pEnabled) {
if (P2pSettingsRepository.isVisible && p2pSettings.p2pEnabled) {
P2pStreamingEngine.warmup()
}
onDispose {
P2pStreamingEngine.cooldownWarmup()
}
}
LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
StreamsRepository.load(
type = type,

View file

@ -0,0 +1,57 @@
package com.nuvio.app.features.streams
import kotlin.test.Test
import kotlin.test.assertEquals
class StreamItemP2pMetadataTest {
@Test
fun usesClientResolveP2pMetadataWhenTopLevelFieldsAreMissing() {
val magnet = "magnet:?xt=urn:btih:ABC123&dn=Movie&tr=udp%3A%2F%2Fmagnet.test%3A80%2Fannounce"
val stream = StreamItem(
name = "Resolved torrent",
addonName = "Addon",
addonId = "addon:test",
sources = listOf(
"tracker:udp://base.test:80/announce",
"dht:ABC123",
),
clientResolve = StreamClientResolve(
infoHash = "ABC123",
fileIdx = 2,
magnetUri = magnet,
filename = "movie.mkv",
sources = listOf(
"tracker:udp://client.test:80/announce",
"tracker:udp://base.test:80/announce",
),
),
)
assertEquals("ABC123", stream.p2pInfoHash)
assertEquals(2, stream.p2pFileIdx)
assertEquals("movie.mkv", stream.p2pFilename)
assertEquals(magnet, stream.torrentMagnetUri)
assertEquals(
listOf(
"udp://base.test:80/announce",
"udp://client.test:80/announce",
),
stream.p2pTrackers,
)
}
@Test
fun extractsP2pInfoHashFromClientResolveMagnet() {
val stream = StreamItem(
name = "Magnet-only torrent",
addonName = "Addon",
addonId = "addon:test",
clientResolve = StreamClientResolve(
magnetUri = "magnet:?xt=urn:btih:def456&dn=Movie",
),
)
assertEquals("def456", stream.p2pInfoHash)
assertEquals("magnet:?xt=urn:btih:def456&dn=Movie", stream.torrentMagnetUri)
}
}

View file

@ -8,6 +8,14 @@ actual object P2pStreamingEngine {
private val _state = MutableStateFlow<P2pStreamingState>(P2pStreamingState.Idle)
actual val state: StateFlow<P2pStreamingState> = _state.asStateFlow()
actual fun warmup() {
_state.value = P2pStreamingState.Idle
}
actual fun cooldownWarmup() {
_state.value = P2pStreamingState.Idle
}
actual suspend fun startStream(request: P2pStreamRequest): String {
val message = "P2P streaming is not available on this platform"
_state.value = P2pStreamingState.Error(message)

View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TORRSERVER_DIR="${TORRSERVER_DIR:-"${ROOT_DIR}/vendor/TorrServer/server"}"
OUT_DIR="${OUT_DIR:-"${ROOT_DIR}/composeApp/src/androidMain/jniLibs"}"
GO_BIN="${GO_BIN:-go}"
UPX_BIN="${UPX_BIN:-upx}"
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-"${HOME}/Library/Android/sdk"}}"
NDK_ROOT="${ANDROID_NDK_HOME:-}"
export GOCACHE="${GOCACHE:-"${ROOT_DIR}/build/go-cache"}"
export GOMODCACHE="${GOMODCACHE:-"${ROOT_DIR}/build/go-mod-cache"}"
if ! command -v "${GO_BIN}" >/dev/null 2>&1; then
echo "Go toolchain not found. Install Go or set GO_BIN=/path/to/go." >&2
exit 1
fi
if [[ ! -d "${TORRSERVER_DIR}" ]]; then
echo "TorrServer source not found at ${TORRSERVER_DIR}" >&2
exit 1
fi
if [[ -z "${NDK_ROOT}" ]]; then
if [[ ! -d "${SDK_ROOT}/ndk" ]]; then
echo "Android NDK not found. Set ANDROID_HOME, ANDROID_SDK_ROOT, or ANDROID_NDK_HOME." >&2
exit 1
fi
NDK_VERSION="$(ls -1 "${SDK_ROOT}/ndk" | sort | tail -n 1)"
NDK_ROOT="${SDK_ROOT}/ndk/${NDK_VERSION}"
fi
PREBUILT_ROOT="${NDK_ROOT}/toolchains/llvm/prebuilt"
HOST_TAG=""
for candidate in darwin-x86_64 linux-x86_64; do
if [[ -d "${PREBUILT_ROOT}/${candidate}" ]]; then
HOST_TAG="${candidate}"
break
fi
done
if [[ -z "${HOST_TAG}" ]]; then
echo "Could not find an LLVM prebuilt toolchain under ${PREBUILT_ROOT}" >&2
exit 1
fi
TOOLCHAIN="${PREBUILT_ROOT}/${HOST_TAG}"
LDFLAGS="${LDFLAGS:-"-s -w -checklinkname=0"}"
BUILD_FLAGS=(-tags=nosqlite -trimpath -ldflags="${LDFLAGS}")
mkdir -p "${GOCACHE}" "${GOMODCACHE}"
build_abi() {
local abi="$1"
local goarch="$2"
local goarm="$3"
local triple="$4"
local api_level="$5"
local cc="${TOOLCHAIN}/bin/${triple}${api_level}-clang"
local cxx="${TOOLCHAIN}/bin/${triple}${api_level}-clang++"
local output="${OUT_DIR}/${abi}/libtorrserver.so"
if [[ ! -x "${cc}" ]]; then
echo "Compiler not found: ${cc}" >&2
exit 1
fi
mkdir -p "$(dirname "${output}")"
echo "Building ${abi} -> ${output}"
local env_vars=(
GOOS=android
GOARCH="${goarch}"
CGO_ENABLED=1
CC="${cc}"
CXX="${cxx}"
)
if [[ -n "${goarm}" ]]; then
env_vars+=(GOARM="${goarm}")
fi
(
cd "${TORRSERVER_DIR}"
env "${env_vars[@]}" "${GO_BIN}" build "${BUILD_FLAGS[@]}" -o "${output}" ./cmd
)
chmod 755 "${output}"
if [[ "${USE_UPX:-0}" == "1" ]] && command -v "${UPX_BIN}" >/dev/null 2>&1; then
"${UPX_BIN}" -q "${output}" || echo "UPX compression failed for ${output}; leaving uncompressed" >&2
fi
}
build_abi "arm64-v8a" "arm64" "" "aarch64-linux-android" "21"
build_abi "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi" "21"
build_abi "x86" "386" "" "i686-linux-android" "21"
build_abi "x86_64" "amd64" "" "x86_64-linux-android" "21"
echo "TorrServer Android binaries updated in ${OUT_DIR}"

View file

@ -140,46 +140,75 @@ android_flavor_task_part() {
esac
}
android_apk_path() {
android_apk_dir() {
local flavor="$1"
case "$flavor" in
full)
echo "$ROOT_DIR/composeApp/build/outputs/apk/full/debug/composeApp-full-debug.apk"
echo "$ROOT_DIR/composeApp/build/outputs/apk/full/debug"
;;
playstore)
echo "$ROOT_DIR/composeApp/build/outputs/apk/playstore/debug/composeApp-playstore-debug.apk"
echo "$ROOT_DIR/composeApp/build/outputs/apk/playstore/debug"
;;
esac
}
build_android_apk() {
android_device_primary_abi() {
local serial="$1"
adb -s "$serial" shell getprop ro.product.cpu.abi | tr -d '\r'
}
android_split_apk_path() {
local flavor="$1"
local flavor_task_part
local serial="$2"
local apk_dir
local abi
local apk_path
flavor_task_part="$(android_flavor_task_part "$flavor")"
apk_path="$(android_apk_path "$flavor")"
apk_dir="$(android_apk_dir "$flavor")"
abi="$(android_device_primary_abi "$serial")"
echo "Building Android $flavor debug APK..." >&2
"$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug" >&2
apk_path="$(find "$apk_dir" -maxdepth 1 -type f -name "*-${abi}-debug.apk" | sort | head -n 1)"
if [[ -z "$apk_path" ]]; then
apk_path="$(find "$apk_dir" -maxdepth 1 -type f -name "*${abi}*.apk" | sort | head -n 1)"
fi
if [[ ! -f "$apk_path" ]]; then
echo "Expected APK not found at: $apk_path" >&2
if [[ -z "$apk_path" || ! -f "$apk_path" ]]; then
echo "Expected split APK for ABI '$abi' not found in: $apk_dir" >&2
find "$apk_dir" -maxdepth 1 -type f -name "*.apk" -print >&2 || true
exit 1
fi
printf '%s\n' "$apk_path"
}
build_android_apks() {
local flavor="$1"
local flavor_task_part
local apk_dir
flavor_task_part="$(android_flavor_task_part "$flavor")"
apk_dir="$(android_apk_dir "$flavor")"
echo "Building Android $flavor debug split APKs..." >&2
"$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug" >&2
if ! find "$apk_dir" -maxdepth 1 -type f -name "*.apk" | grep -q .; then
echo "Expected split APKs not found in: $apk_dir" >&2
exit 1
fi
}
install_and_launch_android() {
local device_label="$1"
local apk_path="$2"
local flavor="$2"
shift 2
local apk_path
local serial
for serial in "$@"; do
echo "Installing on $device_label $serial..."
apk_path="$(android_split_apk_path "$flavor" "$serial")"
echo "Installing on $device_label $serial: $apk_path"
adb -s "$serial" install -r "$apk_path"
echo "Launching app on $serial..."
@ -248,10 +277,9 @@ run_android_emulator() {
wait_for_android_emulator "$serial"
done
local apk_path
apk_path="$(build_android_apk "$flavor")"
build_android_apks "$flavor"
install_and_launch_android "emulator" "$apk_path" "${booted_serials[@]}"
install_and_launch_android "emulator" "$flavor" "${booted_serials[@]}"
}
run_android_physical() {
@ -280,10 +308,9 @@ run_android_physical() {
wait_for_android_device "$serial"
done
local apk_path
apk_path="$(build_android_apk "$flavor")"
build_android_apks "$flavor"
install_and_launch_android "physical device" "$apk_path" "${serials[@]}"
install_and_launch_android "physical device" "$flavor" "${serials[@]}"
}
run_ios_simulator() {