Playback fixes: stall watchdog, DVD and MPEG-2, LAN streaming, Dolby Vision
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user