mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-09-21 00:39:01 +00:00
Made chromecast more robust. Improvements to IPv6 handling of casting devices.
This commit is contained in:
parent
033a237488
commit
80d78761bf
3 changed files with 159 additions and 32 deletions
|
@ -31,6 +31,12 @@ import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InterfaceAddress
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.SocketException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
@ -331,4 +337,98 @@ fun ByteArray.fromGzip(): ByteArray {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return outputStream.toByteArray()
|
return outputStream.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun findPreferredAddress(): InetAddress? {
|
||||||
|
val candidates = NetworkInterface.getNetworkInterfaces()
|
||||||
|
.toList()
|
||||||
|
.asSequence()
|
||||||
|
.filter(::isUsableInterface)
|
||||||
|
.flatMap { nif ->
|
||||||
|
nif.interfaceAddresses
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { ia ->
|
||||||
|
ia.address.takeIf(::isUsableAddress)?.let { addr ->
|
||||||
|
nif to ia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.minWithOrNull(
|
||||||
|
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
|
||||||
|
{ addressScore(it.second.address) },
|
||||||
|
{ interfaceScore(it.first) },
|
||||||
|
{ -it.second.networkPrefixLength.toInt() },
|
||||||
|
{ -it.first.mtu }
|
||||||
|
)
|
||||||
|
)?.second?.address
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUsableInterface(nif: NetworkInterface): Boolean {
|
||||||
|
val name = nif.name.lowercase()
|
||||||
|
return try {
|
||||||
|
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
|
||||||
|
nif.isUp
|
||||||
|
&& !nif.isLoopback
|
||||||
|
&& !nif.isPointToPoint
|
||||||
|
&& !nif.isVirtual
|
||||||
|
&& !name.startsWith("docker")
|
||||||
|
&& !name.startsWith("veth")
|
||||||
|
&& !name.startsWith("br-")
|
||||||
|
&& !name.startsWith("virbr")
|
||||||
|
&& !name.startsWith("vmnet")
|
||||||
|
&& !name.startsWith("tun")
|
||||||
|
&& !name.startsWith("tap")
|
||||||
|
} catch (e: SocketException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUsableAddress(addr: InetAddress): Boolean {
|
||||||
|
return when {
|
||||||
|
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
|
||||||
|
addr.isLoopbackAddress -> false
|
||||||
|
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
|
||||||
|
addr.isMulticastAddress -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun interfaceScore(nif: NetworkInterface): Int {
|
||||||
|
val name = nif.name.lowercase()
|
||||||
|
return when {
|
||||||
|
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
|
||||||
|
name.startsWith("eth") || name.contains("ethernet") -> 0
|
||||||
|
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
|
||||||
|
name.contains("wi-fi") || name.contains("wifi") -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addressScore(addr: InetAddress): Int {
|
||||||
|
return when (addr) {
|
||||||
|
is Inet4Address -> {
|
||||||
|
val octets = addr.address.map { it.toInt() and 0xFF }
|
||||||
|
when {
|
||||||
|
octets[0] == 10 -> 0 // 10/8
|
||||||
|
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
|
||||||
|
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.16–31/12
|
||||||
|
else -> 1 // public IPv4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Inet6Address -> {
|
||||||
|
// ULA (fc00::/7) vs global vs others
|
||||||
|
val b0 = addr.address[0].toInt() and 0xFF
|
||||||
|
when {
|
||||||
|
(b0 and 0xFE) == 0xFC -> 2 // ULA
|
||||||
|
(b0 and 0xE0) == 0x20 -> 3 // global
|
||||||
|
else -> 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
|
@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
|
||||||
import com.futo.platformplayer.toInetAddress
|
import com.futo.platformplayer.toInetAddress
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
private var _mediaSessionId: Int? = null;
|
private var _mediaSessionId: Int? = null;
|
||||||
private var _thread: Thread? = null;
|
private var _thread: Thread? = null;
|
||||||
private var _pingThread: Thread? = null;
|
private var _pingThread: Thread? = null;
|
||||||
|
private var _launchRetries = 0
|
||||||
|
private val MAX_LAUNCH_RETRIES = 3
|
||||||
|
private var _lastLaunchTime_ms = 0L
|
||||||
|
private var _retryJob: Job? = null
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
launchObject.put("appId", "CC1AD845");
|
launchObject.put("appId", "CC1AD845");
|
||||||
launchObject.put("requestId", _requestId++);
|
launchObject.put("requestId", _requestId++);
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||||
|
_lastLaunchTime_ms = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getStatus() {
|
private fun getStatus() {
|
||||||
|
@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
_contentType = null;
|
_contentType = null;
|
||||||
_streamType = null;
|
_streamType = null;
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
|
_launchRetries = 0
|
||||||
_transportId = null;
|
_transportId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
|
|
||||||
_started = true;
|
_started = true;
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
|
_launchRetries = 0
|
||||||
_mediaSessionId = null;
|
_mediaSessionId = null;
|
||||||
|
|
||||||
Logger.i(TAG, "Starting...");
|
Logger.i(TAG, "Starting...");
|
||||||
|
@ -393,7 +402,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
try {
|
try {
|
||||||
val inputStream = _inputStream ?: break;
|
val inputStream = _inputStream ?: break;
|
||||||
|
|
||||||
synchronized(_inputStreamLock)
|
val message = synchronized(_inputStreamLock)
|
||||||
{
|
{
|
||||||
Log.d(TAG, "Receiving next packet...");
|
Log.d(TAG, "Receiving next packet...");
|
||||||
val b1 = inputStream.readUnsignedByte();
|
val b1 = inputStream.readUnsignedByte();
|
||||||
|
@ -405,7 +414,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
if (size > buffer.size) {
|
if (size > buffer.size) {
|
||||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||||
inputStream.skip(size.toLong());
|
inputStream.skip(size.toLong());
|
||||||
return@synchronized
|
return@synchronized null
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||||
|
@ -414,15 +423,19 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||||
Logger.i(TAG, "Received message: $message");
|
Logger.i(TAG, "Received message: $msg");
|
||||||
}
|
}
|
||||||
|
return@synchronized msg
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message != null) {
|
||||||
try {
|
try {
|
||||||
handleMessage(message);
|
handleMessage(message);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to handle message.", e);
|
Logger.w(TAG, "Failed to handle message.", e);
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: java.net.SocketException) {
|
} catch (e: java.net.SocketException) {
|
||||||
|
@ -512,6 +525,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
if (_sessionId == null) {
|
if (_sessionId == null) {
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
connectionState = CastConnectionState.CONNECTED;
|
||||||
_sessionId = applicationUpdate.getString("sessionId");
|
_sessionId = applicationUpdate.getString("sessionId");
|
||||||
|
_launchRetries = 0
|
||||||
|
|
||||||
val transportId = applicationUpdate.getString("transportId");
|
val transportId = applicationUpdate.getString("transportId");
|
||||||
connectMediaChannel(transportId);
|
connectMediaChannel(transportId);
|
||||||
|
@ -526,21 +540,40 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionIsRunning) {
|
if (!sessionIsRunning) {
|
||||||
_sessionId = null;
|
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||||
_mediaSessionId = null;
|
_sessionId = null
|
||||||
setTime(0.0);
|
_mediaSessionId = null
|
||||||
_transportId = null;
|
setTime(0.0)
|
||||||
Logger.w(TAG, "Session not found.");
|
_transportId = null
|
||||||
|
|
||||||
if (_launching) {
|
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||||
Logger.i(TAG, "Player not found, launching.");
|
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||||
launchPlayer();
|
_launchRetries++
|
||||||
|
launchPlayer()
|
||||||
|
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||||
|
// Maybe the first GET_STATUS came back empty; still try launching
|
||||||
|
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||||
|
_launching = true
|
||||||
|
_launchRetries++
|
||||||
|
launchPlayer()
|
||||||
|
} else {
|
||||||
|
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
|
||||||
|
Logger.i(TAG, "Unable to start media receiver on device")
|
||||||
|
stop()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.i(TAG, "Player not found, disconnecting."); //TODO: Add recovery from this scenario ?
|
if (_retryJob == null) {
|
||||||
stop();
|
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
||||||
|
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
||||||
|
delay(5000)
|
||||||
|
getStatus()
|
||||||
|
_retryJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_launching = false;
|
_launching = false
|
||||||
|
_launchRetries = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
val volume = status.getJSONObject("volume");
|
val volume = status.getJSONObject("volume");
|
||||||
|
@ -582,6 +615,8 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
if (message.sourceId == "receiver-0") {
|
if (message.sourceId == "receiver-0") {
|
||||||
Logger.i(TAG, "Close received.");
|
Logger.i(TAG, "Close received.");
|
||||||
stop();
|
stop();
|
||||||
|
} else if (_transportId == message.sourceId) {
|
||||||
|
throw Exception("Transport id closed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -616,6 +651,9 @@ class ChromecastCastingDevice : CastingDevice {
|
||||||
localAddress = null;
|
localAddress = null;
|
||||||
_started = false;
|
_started = false;
|
||||||
|
|
||||||
|
_retryJob?.cancel()
|
||||||
|
_retryJob = null
|
||||||
|
|
||||||
val socket = _socket;
|
val socket = _socket;
|
||||||
val scopeIO = _scopeIO;
|
val scopeIO = _scopeIO;
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,7 @@ import com.futo.platformplayer.builders.DashBuilder
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||||
|
import com.futo.platformplayer.findPreferredAddress
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.parsers.HLS
|
import com.futo.platformplayer.parsers.HLS
|
||||||
|
@ -61,6 +62,7 @@ import java.net.Inet6Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.util.Collections
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class StateCasting {
|
class StateCasting {
|
||||||
|
@ -1216,26 +1218,13 @@ class StateCasting {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findFirstIPv4(): InetAddress? {
|
|
||||||
val interfaces = NetworkInterface.getNetworkInterfaces()
|
|
||||||
for (intf in interfaces) {
|
|
||||||
if (!intf.isUp || intf.isLoopback) continue
|
|
||||||
for (addr in intf.inetAddresses) {
|
|
||||||
if (addr is Inet4Address && !addr.isLoopbackAddress) {
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLocalUrl(ad: CastingDevice): String {
|
private fun getLocalUrl(ad: CastingDevice): String {
|
||||||
var address = ad.localAddress!!
|
var address = ad.localAddress!!
|
||||||
if (address is Inet6Address && address.isLinkLocalAddress) {
|
if (address is Inet6Address && address.isLinkLocalAddress) {
|
||||||
address = findFirstIPv4() ?: address
|
address = findPreferredAddress() ?: address
|
||||||
Logger.i(TAG, "Selected casting address: $address")
|
Logger.i(TAG, "Selected casting address: $address")
|
||||||
}
|
}
|
||||||
return "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue