|
| 1 | +package com.willbeeching.flix.plex |
| 2 | + |
| 3 | +import kotlinx.coroutines.CancellationException |
| 4 | +import kotlinx.coroutines.channels.Channel |
| 5 | +import kotlinx.coroutines.coroutineScope |
| 6 | +import kotlinx.coroutines.launch |
| 7 | +import java.net.URI |
| 8 | + |
| 9 | +/** |
| 10 | + * Cheap, non-blocking description of the device's active local network, |
| 11 | + * used only to bias connection-candidate ordering toward the same subnet. |
| 12 | + * Built from [java.net.NetworkInterface] enumeration only - never from a |
| 13 | + * real DNS lookup or network call. |
| 14 | + */ |
| 15 | +internal data class LocalNetworkInfo(val ip: String, val prefixLength: Int) |
| 16 | + |
| 17 | +/** |
| 18 | + * Parses the dashed-IP form Plex embeds in .plex.direct hostnames, e.g. |
| 19 | + * "10-0-0-8.abcd1234.plex.direct" -> "10.0.0.8". Returns null for anything |
| 20 | + * that isn't a well-formed dashed IPv4 address in the first DNS label. |
| 21 | + * |
| 22 | + * This is a string-only heuristic - it never performs DNS resolution. |
| 23 | + */ |
| 24 | +internal fun parseDashedPlexDirectIp(host: String): String? { |
| 25 | + if (!host.endsWith(".plex.direct", ignoreCase = true)) return null |
| 26 | + val firstLabel = host.substringBefore('.') |
| 27 | + val octets = firstLabel.split("-") |
| 28 | + if (octets.size != 4) return null |
| 29 | + val values = octets.map { it.toIntOrNull() ?: return null } |
| 30 | + if (values.any { it !in 0..255 }) return null |
| 31 | + return values.joinToString(".") |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * Extracts the host portion of a connection URI, e.g. |
| 36 | + * "https://10.0.0.8:32400" -> "10.0.0.8". Returns null if [uriString] isn't |
| 37 | + * a parseable URI. This does not perform DNS resolution - [URI.getHost] |
| 38 | + * only parses the authority component of the string. |
| 39 | + */ |
| 40 | +internal fun hostOf(uriString: String): String? { |
| 41 | + return try { |
| 42 | + URI(uriString).host |
| 43 | + } catch (e: Exception) { |
| 44 | + null |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Converts a dotted-quad IPv4 string to its 32-bit integer representation, |
| 50 | + * or null if [ip] isn't a valid IPv4 literal. |
| 51 | + */ |
| 52 | +internal fun ipv4ToInt(ip: String): Int? { |
| 53 | + val parts = ip.split(".") |
| 54 | + if (parts.size != 4) return null |
| 55 | + var result = 0 |
| 56 | + for (part in parts) { |
| 57 | + val octet = part.toIntOrNull() ?: return null |
| 58 | + if (octet !in 0..255) return null |
| 59 | + result = (result shl 8) or octet |
| 60 | + } |
| 61 | + return result |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * True if [ipA] and [ipB] fall in the same network under a [prefixLength]-bit |
| 66 | + * CIDR mask. Returns false if either string isn't a valid IPv4 literal. |
| 67 | + */ |
| 68 | +internal fun isSameSubnet(ipA: String, ipB: String, prefixLength: Int): Boolean { |
| 69 | + if (prefixLength <= 0) return true |
| 70 | + if (prefixLength >= 32) return ipA == ipB |
| 71 | + val a = ipv4ToInt(ipA) ?: return false |
| 72 | + val b = ipv4ToInt(ipB) ?: return false |
| 73 | + val mask = -1 shl (32 - prefixLength) |
| 74 | + return (a and mask) == (b and mask) |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Best-effort IPv4 literal for a connection, resolved WITHOUT any DNS: |
| 79 | + * either the connection's host is already a dotted-quad, or it's a |
| 80 | + * .plex.direct hostname whose first label encodes the IP (see |
| 81 | + * [parseDashedPlexDirectIp]). Returns null when the host can only be |
| 82 | + * resolved via real DNS - in that case subnet matching is simply skipped |
| 83 | + * for this candidate (it still gets probed, just not prioritized for it). |
| 84 | + */ |
| 85 | +internal fun candidateIpLiteral(connection: PlexApiClient.Connection): String? { |
| 86 | + val host = hostOf(connection.uri) ?: connection.address |
| 87 | + ipv4ToInt(host)?.let { return host } |
| 88 | + return parseDashedPlexDirectIp(host) |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Whether [connection] can cheaply be shown to sit on the same subnet as |
| 93 | + * [localNetwork]. Always false (never "unknown") when it can't be |
| 94 | + * determined cheaply - this is a sort-order bias, not a filter. |
| 95 | + */ |
| 96 | +internal fun isCandidateOnLocalSubnet( |
| 97 | + connection: PlexApiClient.Connection, |
| 98 | + localNetwork: LocalNetworkInfo? |
| 99 | +): Boolean { |
| 100 | + if (localNetwork == null) return false |
| 101 | + val candidateIp = candidateIpLiteral(connection) ?: return false |
| 102 | + return isSameSubnet(candidateIp, localNetwork.ip, localNetwork.prefixLength) |
| 103 | +} |
| 104 | + |
| 105 | +/** |
| 106 | + * Orders connection candidates for probing. Preference order: |
| 107 | + * 1. Plex's own "local" flag (its opinion of whether this is a LAN address) |
| 108 | + * 2. Our own same-subnet heuristic (cheap, DNS-free - see [isCandidateOnLocalSubnet]) |
| 109 | + * 3. Non-relay over relay |
| 110 | + * 4. https over http |
| 111 | + * |
| 112 | + * NOTE: .plex.direct hostnames are deliberately NOT down-ranked here. They |
| 113 | + * used to be treated as "avoid if possible" because DNS resolution for them |
| 114 | + * was assumed to indicate a non-local/remote connection, but that premise is |
| 115 | + * wrong: plex.tv issues .plex.direct hostnames (which carry a valid wildcard |
| 116 | + * TLS cert) for nearly every connection, including LAN ones. Local-first |
| 117 | + * ordering is preserved above via the `local` flag and the subnet heuristic, |
| 118 | + * not via hostname shape. |
| 119 | + */ |
| 120 | +internal fun sortConnections( |
| 121 | + connections: List<PlexApiClient.Connection>, |
| 122 | + localNetwork: LocalNetworkInfo? |
| 123 | +): List<PlexApiClient.Connection> { |
| 124 | + return connections.sortedWith( |
| 125 | + compareByDescending<PlexApiClient.Connection> { it.local } |
| 126 | + .thenByDescending { isCandidateOnLocalSubnet(it, localNetwork) } |
| 127 | + .thenByDescending { !it.relay } |
| 128 | + .thenByDescending { it.protocol == "https" } |
| 129 | + ) |
| 130 | +} |
| 131 | + |
| 132 | +/** |
| 133 | + * Races [probe] across [items] concurrently using structured concurrency |
| 134 | + * ([coroutineScope] + [launch]) and returns the first non-null result. |
| 135 | + * Every other in-flight probe is cancelled as soon as a winner is found (or |
| 136 | + * once every candidate has been tried and none succeeded) - no losing call |
| 137 | + * is left running past this function returning. |
| 138 | + * |
| 139 | + * A probe that throws is treated as a failure (null), not as a fatal error |
| 140 | + * for the whole race - one bad candidate must not sink the others. |
| 141 | + */ |
| 142 | +internal suspend fun <T, R : Any> raceFirstSuccess( |
| 143 | + items: List<T>, |
| 144 | + probe: suspend (T) -> R? |
| 145 | +): R? = coroutineScope { |
| 146 | + if (items.isEmpty()) return@coroutineScope null |
| 147 | + |
| 148 | + // Buffered so a cancelled/losing job's send never suspends. |
| 149 | + val results = Channel<R?>(capacity = items.size) |
| 150 | + val jobs = items.map { item -> |
| 151 | + launch { |
| 152 | + val outcome = try { |
| 153 | + probe(item) |
| 154 | + } catch (e: CancellationException) { |
| 155 | + throw e |
| 156 | + } catch (e: Exception) { |
| 157 | + null |
| 158 | + } |
| 159 | + results.send(outcome) |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + var winner: R? = null |
| 164 | + var received = 0 |
| 165 | + while (received < items.size && winner == null) { |
| 166 | + winner = results.receive() |
| 167 | + received++ |
| 168 | + } |
| 169 | + jobs.forEach { it.cancel() } |
| 170 | + winner |
| 171 | +} |
0 commit comments