Playback fixes: stall watchdog, DVD and MPEG-2, LAN streaming, Dolby Vision

This commit is contained in:
2026-10-14 09:24:35 +03:00
parent dec4b44571
commit 69a793abcb
28 changed files with 2788 additions and 83 deletions

View File

@@ -2,22 +2,27 @@ package ru.stenfredd.movies.core.jellyfin
import android.content.Context
import android.os.Build
import android.os.SystemClock
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.authenticationApi
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -49,6 +54,30 @@ class JellyfinSession @Inject constructor(
val deviceId: String get() = api.deviceInfo.id
/** Direct LAN address of the same server for media streams, see [LocalServerDiscovery]; null away from home. */
@Volatile
private var localUrl: String? = null
@Volatile
private var localCheckedAt = 0L
private val discoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Looks for the server on the local network in the background (at most every [LOCAL_RECHECK_MS]); playback uses
* whatever is known at that moment and never waits for it.
*/
fun refreshLocalAddress() {
val s = _session.value ?: return
val now = SystemClock.elapsedRealtime()
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
localCheckedAt = now
discoveryScope.launch {
val id = LocalServerDiscovery.serverId(s.serverUrl)
val found = id?.let { LocalServerDiscovery.find(it) }
if (found != localUrl) Timber.i("Media streams: %s", found ?: "через ${s.serverUrl}")
localUrl = found
}
}
/** Restores the saved session. Returns false when the user has to log in. */
suspend fun restore(): Boolean {
val prefs = context.sessionStore.data.first()
@@ -58,6 +87,7 @@ class JellyfinSession @Inject constructor(
val userId = prefs[KEY_USER_ID]?.let(UUID::fromString) ?: return false
api.update(baseUrl = url, accessToken = token)
_session.value = SessionInfo(url, userId, prefs[KEY_USER_NAME].orEmpty(), token)
refreshLocalAddress()
return true
}
@@ -75,6 +105,9 @@ class JellyfinSession @Inject constructor(
it[KEY_USER_NAME] = user.name.orEmpty()
}
_session.value = SessionInfo(url, user.id, user.name.orEmpty(), token)
localUrl = null
localCheckedAt = 0
refreshLocalAddress()
}
suspend fun logout() {
@@ -91,10 +124,18 @@ class JellyfinSession @Inject constructor(
fun requireSession(): SessionInfo = checkNotNull(_session.value) { "Нет активной сессии" }
/** Absolute URL for a server-relative path, with the token as `ApiKey` (engines can't send headers). */
/**
* Absolute URL for a server-relative path or a URL on the server, with the token as `ApiKey` (engines can't send
* headers). Media goes to the LAN address when one was found; API calls keep using [SessionInfo.serverUrl].
*/
fun authorizedUrl(pathOrUrl: String): String {
val s = requireSession()
val absolute = if (pathOrUrl.startsWith("http")) pathOrUrl else s.serverUrl + pathOrUrl
val base = localUrl ?: s.serverUrl
val absolute = when {
pathOrUrl.startsWith(s.serverUrl) -> base + pathOrUrl.removePrefix(s.serverUrl)
pathOrUrl.startsWith("http") -> pathOrUrl
else -> base + pathOrUrl
}
if (absolute.contains("ApiKey=", ignoreCase = true) || absolute.contains("api_key=")) return absolute
val separator = if ('?' in absolute) '&' else '?'
return "$absolute${separator}${ApiClient.QUERY_ACCESS_TOKEN}=${s.accessToken}"
@@ -116,5 +157,6 @@ class JellyfinSession @Inject constructor(
val KEY_USER_ID = stringPreferencesKey("user_id")
val KEY_USER_NAME = stringPreferencesKey("user_name")
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
const val LOCAL_RECHECK_MS = 10 * 60_000L
}
}

View File

@@ -0,0 +1,82 @@
package ru.stenfredd.movies.core.jellyfin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import timber.log.Timber
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.SocketTimeoutException
import java.net.URL
/**
* Finds a direct LAN address of the logged-in server, for streaming.
*
* At home the TV reached the server through its public HTTPS name: router hairpin, reverse proxy, and TLS decrypted
* by the TV's CPU. That capped Exo at 40–115 Mbit/s and a 66 Mbit/s 4K remux rebuffered every
* few seconds, while plain HTTP to the LAN address gives the TV ~240 Mbit/s (2026-10-13).
*
* The server's own `LocalAddress` is the public name too (it is configured that way), so the address comes from
* Jellyfin's UDP discovery (port 7359): the reply's source IP, with the default HTTP port. It is only used if
* `/System/Info/Public` there reports the same server id.
*/
internal object LocalServerDiscovery {
private const val DISCOVERY_PORT = 7359
private const val HTTP_PORT = 8096
private const val TIMEOUT_MS = 1500
private const val MESSAGE = "Who is JellyfinServer?"
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
.onFailure { Timber.d(it, "Server id not read from %s", baseUrl) }
.getOrNull()
}
/** `http://<ip>:8096` of the server with [serverId] on the local network, or null. */
suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) {
val candidates = runCatching { discover(serverId) }.onFailure { Timber.d(it, "Jellyfin discovery failed") }.getOrDefault(emptyList())
candidates.firstOrNull { url -> serverId(url) == serverId }
}
private fun discover(serverId: String): List<String> {
val found = mutableListOf<String>()
DatagramSocket().use { socket ->
socket.broadcast = true
socket.soTimeout = TIMEOUT_MS
val message = MESSAGE.toByteArray()
socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT))
val buffer = ByteArray(4096)
val deadline = System.currentTimeMillis() + TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
val packet = DatagramPacket(buffer, buffer.size)
try {
socket.receive(packet)
} catch (_: SocketTimeoutException) {
break
}
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
if (reply.optString("Id") != serverId) continue
val host = packet.address.hostAddress ?: continue
found += "http://$host:$HTTP_PORT"
// The advertised address, if it happens to be a plain LAN URL, is worth a try as well.
reply.optString("Address").takeIf { it.startsWith("http://") }?.let { found += it.trimEnd('/') }
break
}
}
return found.distinct()
}
private fun get(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
return try {
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
}