mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-18 21:18:32 +00:00
Harden Android torrent lifecycle
This commit is contained in:
parent
09f717f4a5
commit
a6f5d2f075
5 changed files with 68 additions and 39 deletions
|
|
@ -8,7 +8,6 @@ import java.net.HttpURLConnection
|
|||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
|
|
@ -23,47 +22,41 @@ class TorrentEngineComparisonInstrumentationBenchmark {
|
|||
@After
|
||||
fun tearDown() {
|
||||
torrentServerEngine?.stop()
|
||||
FluxaStreamingNative.stopTorrentServer()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compareTorrentServerAndRustEngineFirstRangeRead() {
|
||||
fun measureRustEnginePlaybackScenarios() {
|
||||
val args = InstrumentationRegistry.getArguments()
|
||||
val torrentLink = args.getString("torrentLink").orEmpty()
|
||||
assumeTrue("Pass -e torrentLink <magnet-or-torrent-url> to run this benchmark.", torrentLink.isNotBlank())
|
||||
|
||||
val rustInfo = JSONObject(
|
||||
FluxaStreamingNative.startTorrentServer(
|
||||
cacheDir = context.cacheDir.resolve("rust_torrent_benchmark").absolutePath,
|
||||
preferredPort = 0
|
||||
)
|
||||
)
|
||||
val rustBaseUrl = rustInfo.getString("url")
|
||||
val rustUrl = torrentStreamUrl(rustBaseUrl, torrentLink)
|
||||
|
||||
torrentServerEngine = TorrentServerEngine(context).also { it.start() }
|
||||
waitForHttp(Constants.LocalServer.TORRENT_SERVER_BASE_URL)
|
||||
val torrentServerUrl = torrentStreamUrl(Constants.LocalServer.TORRENT_SERVER_BASE_URL, torrentLink)
|
||||
val streamUrl = torrentStreamUrl(Constants.LocalServer.TORRENT_SERVER_BASE_URL, torrentLink)
|
||||
|
||||
val rustDelayMs = measureFirstRangeMs(rustUrl)
|
||||
val torrentServerDelayMs = measureFirstRangeMs(torrentServerUrl)
|
||||
val firstByteMs = measureFirstRangeMs(streamUrl, 0)
|
||||
val length = streamLength(streamUrl)
|
||||
val middleSeekMs = measureFirstRangeMs(streamUrl, length / 2)
|
||||
val tailSeekMs = measureFirstRangeMs(streamUrl, (length - 262_144L).coerceAtLeast(0L))
|
||||
|
||||
println("torrent-rust-engine-first-range-ms=$rustDelayMs")
|
||||
println("torrent-torrserver-first-range-ms=$torrentServerDelayMs")
|
||||
assertTrue(rustDelayMs > 0)
|
||||
assertTrue(torrentServerDelayMs > 0)
|
||||
println("torrent-rust-engine-first-byte-ms=$firstByteMs")
|
||||
println("torrent-rust-engine-middle-seek-first-byte-ms=$middleSeekMs")
|
||||
println("torrent-rust-engine-tail-seek-first-byte-ms=$tailSeekMs")
|
||||
assertTrue(firstByteMs > 0)
|
||||
assertTrue(middleSeekMs > 0)
|
||||
assertTrue(tailSeekMs > 0)
|
||||
}
|
||||
|
||||
private fun torrentStreamUrl(baseUrl: String, torrentLink: String): String {
|
||||
return "$baseUrl/stream/fname?link=${URLEncoder.encode(torrentLink, "UTF-8")}&title=benchmark"
|
||||
}
|
||||
|
||||
private fun measureFirstRangeMs(url: String): Long {
|
||||
private fun measureFirstRangeMs(url: String, startByte: Long): Long {
|
||||
val start = System.nanoTime()
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
connection.setRequestProperty("Range", "bytes=0-262143")
|
||||
connection.setRequestProperty("Range", "bytes=$startByte-${startByte + 262_143L}")
|
||||
val bytes = connection.inputStream.use { stream ->
|
||||
val buffer = ByteArray(32 * 1024)
|
||||
var total = 0
|
||||
|
|
@ -78,6 +71,18 @@ class TorrentEngineComparisonInstrumentationBenchmark {
|
|||
return (System.nanoTime() - start) / 1_000_000L
|
||||
}
|
||||
|
||||
private fun streamLength(url: String): Long {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
connection.setRequestProperty("Range", "bytes=0-0")
|
||||
connection.inputStream.use { it.read() }
|
||||
return connection.getHeaderField("Content-Range")
|
||||
?.substringAfter('/')
|
||||
?.toLongOrNull()
|
||||
?: error("Torrent server did not return Content-Range")
|
||||
}
|
||||
|
||||
private fun waitForHttp(baseUrl: String) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20)
|
||||
while (System.nanoTime() < deadline) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ object FluxaStreamingNative {
|
|||
startTorrentServerNative(cacheDir, preferredPort, accessToken)
|
||||
}
|
||||
|
||||
fun stopTorrentServer(): Boolean = call { stopTorrentServerNative() }
|
||||
fun stopTorrentServer(expectedGeneration: Long? = null): Boolean = call {
|
||||
stopTorrentServerNative(expectedGeneration ?: -1L)
|
||||
}
|
||||
|
||||
fun dvRpuSelfTest(): Boolean = call { dvRpuSelfTestNative() }
|
||||
|
||||
|
|
@ -59,7 +61,7 @@ object FluxaStreamingNative {
|
|||
private external fun startDvRewriteLocalStreamServerNative(targetUrl: String, headersJson: String, dvConfigJson: String, preferredPort: Int): String
|
||||
private external fun stopLocalStreamServerNative(serverId: String): Boolean
|
||||
private external fun startTorrentServerNative(cacheDir: String, preferredPort: Int, accessToken: String): String
|
||||
private external fun stopTorrentServerNative(): Boolean
|
||||
private external fun stopTorrentServerNative(expectedGeneration: Long): Boolean
|
||||
private external fun dvRpuSelfTestNative(): Boolean
|
||||
private external fun dvAutoDetectWasIptPqc2Native(): Boolean
|
||||
private external fun dvRewriteSegmentBytesNative(data: ByteArray, rpuMode: Int, zeroLevel5: Boolean, removeHdr10Plus: Boolean): ByteArray
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ data class TorrentRequest(
|
|||
val hash: String? = null,
|
||||
val title: String? = null,
|
||||
@SerializedName("save_to_db") val saveToDb: Boolean = false,
|
||||
@SerializedName("file_id") val fileId: Int? = null
|
||||
@SerializedName("file_id") val fileId: Int? = null,
|
||||
// Defaults to video for old callers. Subtitle selection can opt in
|
||||
// without replacing the torrent's current primary video focus.
|
||||
val role: String = "video"
|
||||
)
|
||||
|
||||
data class TorrentSettings(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import android.content.Context
|
|||
import android.util.Log
|
||||
import com.fluxa.app.core.rust.FluxaStreamingNative
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.ServerSocket
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class TorrentServerEngine(private val context: Context) {
|
||||
|
|
@ -19,22 +22,22 @@ class TorrentServerEngine(private val context: Context) {
|
|||
private var watcherJob: Job? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
@Volatile private var running = false
|
||||
@Volatile private var generation: Long? = null
|
||||
|
||||
fun start() {
|
||||
if (isRunning()) return
|
||||
if (isRunning() && healthResponds()) return
|
||||
|
||||
ensurePortFree()
|
||||
val legacyDataDir = java.io.File(context.filesDir, "rust_torrent_cache")
|
||||
if (legacyDataDir.exists()) legacyDataDir.deleteRecursively()
|
||||
val dataDir = java.io.File(context.cacheDir, "rust_torrent_cache").apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
// Keep torrent pieces and librqbit's session persistence across normal
|
||||
// app/engine restarts. Cache removal is an explicit user action, not
|
||||
// a lifecycle side effect.
|
||||
val dataDir = java.io.File(context.filesDir, "rust_torrent_cache").apply { mkdirs() }
|
||||
|
||||
try {
|
||||
castAccessToken = UUID.randomUUID().toString()
|
||||
val result = FluxaStreamingNative.startTorrentServer(dataDir.absolutePath, port, castAccessToken)
|
||||
running = result.isNotBlank()
|
||||
generation = runCatching { JSONObject(result).getLong("generation") }.getOrNull()
|
||||
running = generation != null
|
||||
if (running) {
|
||||
Log.i("TorrentServer", "Rust torrent engine started on port $port")
|
||||
startWatcher()
|
||||
|
|
@ -55,8 +58,9 @@ class TorrentServerEngine(private val context: Context) {
|
|||
watcherJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(3000)
|
||||
if (!isRunning()) {
|
||||
Log.w("TorrentServer", "Rust torrent engine stopped. Restarting...")
|
||||
if (!healthResponds()) {
|
||||
Log.w("TorrentServer", "Rust torrent engine health check failed. Restarting...")
|
||||
FluxaStreamingNative.stopTorrentServer(generation)
|
||||
running = false
|
||||
start()
|
||||
}
|
||||
|
|
@ -68,8 +72,9 @@ class TorrentServerEngine(private val context: Context) {
|
|||
watcherJob?.cancel()
|
||||
Log.i("TorrentServer", "Stopping Rust torrent engine...")
|
||||
try {
|
||||
FluxaStreamingNative.stopTorrentServer()
|
||||
FluxaStreamingNative.stopTorrentServer(generation)
|
||||
running = false
|
||||
generation = null
|
||||
castAccessToken = ""
|
||||
} catch (e: Exception) {
|
||||
Log.e("TorrentServer", "Rust torrent engine stop failed", e)
|
||||
|
|
@ -85,6 +90,16 @@ class TorrentServerEngine(private val context: Context) {
|
|||
return running
|
||||
}
|
||||
|
||||
private fun healthResponds(): Boolean = runCatching {
|
||||
(URL("http://127.0.0.1:$port/health").openConnection() as HttpURLConnection).run {
|
||||
connectTimeout = 1_000
|
||||
readTimeout = 1_000
|
||||
requestMethod = "GET"
|
||||
connect()
|
||||
responseCode in 200..299
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun ensurePortFree() {
|
||||
try {
|
||||
ServerSocket(port).use { /* Port is free */ }
|
||||
|
|
|
|||
|
|
@ -276,11 +276,15 @@ class TorrentStreamManager private constructor() {
|
|||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
// 1% of file size, clamped between 3 MB and 24 MB.
|
||||
// Falls back to the speed-preset default when file size is unknown.
|
||||
// Target ~20 seconds of media rather than a fixed fraction of a file.
|
||||
// A size-only target makes high-bitrate remuxes start with just a couple
|
||||
// of seconds buffered while over-buffering low-bitrate content.
|
||||
private fun estimatePreloadMb(fileSizeBytes: Long, durationMs: Long): Long {
|
||||
if (fileSizeBytes > 0L) {
|
||||
return (fileSizeBytes / 100L / (1024L * 1024L)).coerceIn(3L, 24L)
|
||||
if (fileSizeBytes > 0L && durationMs > 0L) {
|
||||
val bytesPerSecond = fileSizeBytes.toDouble() / (durationMs / 1_000.0)
|
||||
return (bytesPerSecond * 20.0 / (1024.0 * 1024.0))
|
||||
.toLong()
|
||||
.coerceIn(4L, 256L)
|
||||
}
|
||||
return pendingSettings.preloadSize
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue