From e7d4243271d1b6e8401b7a4b47ea4133a4e44403 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Fri, 6 Nov 2026 04:02:20 +0000 Subject: [PATCH] Jellyfin: Orbit speaks to Jellyfin servers as well as Emby Ported from release 0.108.44. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/java/tv/orbit/classic/BoxDecoders.kt | 9 +-- .../main/java/tv/orbit/classic/EmbyClient.kt | 46 ++++++++++-- .../tv/orbit/classic/EmbyConnectionScreen.kt | 2 +- .../tv/orbit/classic/EmbyPlaybackProtocol.kt | 10 ++- .../main/java/tv/orbit/classic/Onboarding.kt | 10 +-- .../java/tv/orbit/classic/EmbyServerTest.kt | 6 +- .../java/tv/orbit/classic/JellyfinLiveTest.kt | 70 +++++++++++++++++++ 7 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 android/app/src/test/java/tv/orbit/classic/JellyfinLiveTest.kt diff --git a/android/app/src/main/java/tv/orbit/classic/BoxDecoders.kt b/android/app/src/main/java/tv/orbit/classic/BoxDecoders.kt index 7666f7b..90ab3e5 100644 --- a/android/app/src/main/java/tv/orbit/classic/BoxDecoders.kt +++ b/android/app/src/main/java/tv/orbit/classic/BoxDecoders.kt @@ -33,10 +33,11 @@ internal fun hasEnglishAudio(source: org.json.JSONObject): Boolean { /** The decoders this box actually has, read once. */ internal object BoxDecoders { - private val types by lazy { - MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.filterNot { it.isEncoder }.flatMap { it.supportedTypes.asList() }.map { it.lowercase(java.util.Locale.ROOT) }.toSet() + private val types: Set? by lazy { + runCatching { MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.filterNot { it.isEncoder }.flatMap { it.supportedTypes.asList() }.map { it.lowercase(java.util.Locale.ROOT) }.toSet() }.getOrNull() } - fun canDecode(codec: String): Boolean = videoMimeFor(codec)?.let { it in types } ?: true + /** Whether the box can decode [codec]; if its decoders can't be listed at all (off the box), the benefit of the doubt. */ + fun canDecode(codec: String): Boolean = videoMimeFor(codec)?.let { mime -> types?.let { mime in it } } ?: true /** Whether the box has a decoder for this exact type, for diagnostics. */ - fun has(mime: String): Boolean = mime.lowercase(java.util.Locale.ROOT) in types + fun has(mime: String): Boolean = types?.contains(mime.lowercase(java.util.Locale.ROOT)) == true } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 450e2b8..d94a18f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -15,6 +15,13 @@ internal class EmbyReply(val status: Int, val body: String) internal fun interface EmbyTransport { fun execute(request: EmbyRequest): EmbyReply } /** Network boundary: one fixed origin (HTTPS, or a home server: see [EmbyServer]), standard TLS checks, no redirects or response logging. */ +/** + * The kind of media server (Jellyfin support, Paul, 6 November 2026). Jellyfin grew out of Emby and speaks the same API for + * everything Orbit asks, so one connection serves both; Jellyfin's addresses have no /emby in front and it takes its sign-in + * in the standard Authorization header. + */ +internal enum class ServerKind(val prefix: String, val label: String) { Emby("/emby", "Emby"), Jellyfin("", "Jellyfin") } + internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUID().toString(), /** The one server this transport ever talks to, fixed when it's made, so a session never follows a change of server. */ internal val origin: String = EmbyServer.origin) : EmbyTransport { @@ -35,16 +42,42 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI return output.toString(Charsets.UTF_8.name()) } } + @Volatile private var known: ServerKind? = null + /** What this server is, asked once (its public information needs no sign-in). Emby unless it says it's Jellyfin. */ + internal val kind: ServerKind get() = known ?: synchronized(this) { known ?: detect().also { known = it } } + private fun detect(): ServerKind { + EmbyServer.checkHomeConnection(origin) + for (path in listOf("/emby/System/Info/Public", "/System/Info/Public")) { + val connection = URL("$origin$path").openConnection() as HttpURLConnection + try { + connection.instanceFollowRedirects = false; connection.connectTimeout = 10_000; connection.readTimeout = 15_000 + if (connection.responseCode != 200) continue + val product = runCatching { org.json.JSONObject(connection.inputStream.use { readBounded(it) }).optString("ProductName") }.getOrDefault("") + return if (product.contains("Jellyfin", true)) ServerKind.Jellyfin else ServerKind.Emby + } catch (_: javax.net.ssl.SSLException) { throw EmbyCheckException("The secure connection could not be verified. No insecure fallback was used.") } + catch (_: java.io.IOException) { continue } + finally { connection.disconnect() } + } + throw EmbyCheckException("That address didn't answer as a media server.", retryable = true) + } + /** The sign-in headers for this kind of server. */ + internal fun authHeaders(token: String?): Map = when (kind) { + ServerKind.Emby -> mapOf("X-Emby-Authorization" to "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.111.0\"") + + (token?.let { mapOf("X-Emby-Token" to it) } ?: emptyMap()) + ServerKind.Jellyfin -> mapOf("Authorization" to "MediaBrowser Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.111.0\"" + + (token?.let { ", Token=\"$it\"" } ?: "")) + } /** One picture from the provider, or nothing. Same fixed origin and no redirects, and failures are never shown or logged. */ fun fetchImage(path: String, token: String): ByteArray? { require(path.startsWith("/") && !path.startsWith("//")) if (runCatching { EmbyServer.checkHomeConnection(origin) }.isFailure) return null - val connection = URL("$origin/emby$path").openConnection() as HttpURLConnection + val prefix = runCatching { kind.prefix }.getOrNull() ?: return null + val connection = URL("$origin$prefix$path").openConnection() as HttpURLConnection try { connection.instanceFollowRedirects = false connection.connectTimeout = 10_000 connection.readTimeout = 15_000 - connection.setRequestProperty("X-Emby-Token", token) + authHeaders(token).forEach { (key, value) -> connection.setRequestProperty(key, value) } if (connection.responseCode != 200 || connection.contentType?.startsWith("image/") != true || connection.contentLengthLong > MAX_IMAGE_BYTES) return null val output = ByteArrayOutputStream() connection.inputStream.use { stream -> @@ -66,15 +99,14 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI // HTTPS, or plain HTTP to a home server whose name still leads only to private addresses. require(origin.startsWith("https://") || EmbyServer.isHome(origin)) EmbyServer.checkHomeConnection(origin) - val connection = URL("$origin/emby${request.path}").openConnection() as HttpURLConnection + val connection = URL("$origin${kind.prefix}${request.path}").openConnection() as HttpURLConnection try { connection.requestMethod = request.method connection.instanceFollowRedirects = false connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.111.0\"") - request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } + authHeaders(request.token).forEach { (key, value) -> connection.setRequestProperty(key, value) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) connection.doOutput = true @@ -106,8 +138,8 @@ internal class EmbyClient(private val transport: EmbyTransport = HttpsEmbyTransp if (reply.status !in 200..299) throw EmbyCheckException("That address didn't answer as an Emby server.", reply.status) val info = runCatching { JSONObject(reply.body) }.getOrNull() ?: throw EmbyCheckException("That address didn't answer as an Emby server.") if (info.optString("Id").isBlank() || info.optString("Version").isBlank()) throw EmbyCheckException("That address didn't answer as an Emby server.") - if (info.optString("ProductName").contains("Jellyfin", true)) throw EmbyCheckException("That's a Jellyfin server. Orbit works with Emby for now; Jellyfin is coming.") - return info.optString("ServerName").take(80).filterNot { it.isISOControl() }.ifBlank { "Emby" } + val jellyfin = info.optString("ProductName").contains("Jellyfin", true) + return info.optString("ServerName").take(80).filterNot { it.isISOControl() }.ifBlank { if (jellyfin) "Jellyfin" else "Emby" } } fun connect(username: String, password: String): EmbySampleSession { if (username.isBlank() || username.length > 256 || password.isEmpty() || password.length > 1024) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index e9a57e1..1e42371 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -631,7 +631,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(Modifier.width(460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Sign in to Emby", color = Ink, fontSize = 26.sp, fontWeight = FontWeight.SemiBold) + Text("Sign in to Emby or Jellyfin", color = Ink, fontSize = 26.sp, fontWeight = FontWeight.SemiBold) // The server's address (release essentials, 6 November 2026): typed in, HTTPS, or plain HTTP to a home server only. Text("Server address", color = Muted, fontSize = 14.sp) var serverFocused by remember { mutableStateOf(false) } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index 455f895..1b005b4 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -61,6 +61,9 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v val accountId get() = userId /** The server this session belongs to, for good: its media addresses and token go nowhere else. */ val origin: String get() = (transport as? HttpsEmbyTransport)?.origin ?: EmbyServer.origin + /** Where this server's API starts: /emby on Emby, the root on Jellyfin. */ + private val kind: ServerKind get() = (transport as? HttpsEmbyTransport)?.let { runCatching { it.kind }.getOrNull() } ?: ServerKind.Emby + private val apiBase: String get() = origin + kind.prefix fun rememberedIdentity() = RememberedIdentity(userId, currentToken(), (transport as? HttpsEmbyTransport)?.deviceId ?: "fixture-device") fun matchesAccount(identity: RememberedIdentity) = userId == identity.userId @@ -80,10 +83,11 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v checkedEpisodes = emptyList(); candidateEpisodes = emptyMap(); channelEpisodes = emptyMap(); darkCandidates = emptyList(); darkLineup = emptyMap() } private fun currentToken() = synchronized(this) { token ?: throw EmbyCheckException("This sample session has ended. Sign in again.") } - fun mediaUrl(value: String, base: String = "$origin/emby/") = PlaybackUrls.resolve(value, base, currentToken(), origin) + fun mediaUrl(value: String, base: String = "$apiBase/") = PlaybackUrls.resolve(value, base, currentToken(), origin) fun mediaHeaders(url: String): Map { val auth = currentToken() - return if (PlaybackUrls.isProvider(URI(url), origin)) mapOf("X-Emby-Token" to auth) else emptyMap() + if (!PlaybackUrls.isProvider(URI(url), origin)) return emptyMap() + return if (kind == ServerKind.Jellyfin) mapOf("Authorization" to "MediaBrowser Token=\"$auth\"") else mapOf("X-Emby-Token" to auth) } /** Tuning shouldn't fail on one slow answer, so a timeout or dropped connection gets one more try. */ fun prepareForTuning(item: SampleItem, sourceId: String, pause: () -> Unit = {}): EmbyPlaybackPlan = try { @@ -122,7 +126,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v val playId = (if (info.isNull("PlaySessionId")) "" else info.optString("PlaySessionId")).ifBlank { java.util.UUID.randomUUID().toString() } if (!EmbyClient.validId(playId)) throw EmbyCheckException("The provider returned an unusable playback identity.") val container = requireNotNull(supportedDirectContainer(source.optString("Container"))) - val url = "$origin/emby/Videos/${item.id}/stream.$container?Static=true&MediaSourceId=${encode(sourceId)}&PlaySessionId=${encode(playId)}" + val url = "$apiBase/Videos/${item.id}/stream.$container?Static=true&MediaSourceId=${encode(sourceId)}&PlaySessionId=${encode(playId)}" val streams = source.optJSONArray("MediaStreams") val subtitles = (0 until minOf(streams?.length() ?: 0, 32)).mapNotNull { index -> val stream = streams?.optJSONObject(index) ?: return@mapNotNull null diff --git a/android/app/src/main/java/tv/orbit/classic/Onboarding.kt b/android/app/src/main/java/tv/orbit/classic/Onboarding.kt index 9bb469d..3f660e4 100644 --- a/android/app/src/main/java/tv/orbit/classic/Onboarding.kt +++ b/android/app/src/main/java/tv/orbit/classic/Onboarding.kt @@ -120,7 +120,7 @@ internal fun OnboardingScreen(signedIn: Boolean, onDone: (tour: Boolean) -> Unit OnboardStep.Look -> LookStep { Onboarding.go(OnboardStep.Server) } OnboardStep.Server -> ServerStep { Onboarding.go(OnboardStep.SignIn) } OnboardStep.SignIn -> if (Onboarding.tour) TourSignedIn { Onboarding.go(OnboardStep.Channels) } - else Text("Sign in with your Emby account. Your password is never saved.", Modifier.align(Alignment.BottomCenter), color = Muted, fontSize = 13.sp) + else Text("Sign in with your Emby or Jellyfin account. Your password is never saved.", Modifier.align(Alignment.BottomCenter), color = Muted, fontSize = 13.sp) OnboardStep.Channels -> ChannelsStep { Onboarding.go(OnboardStep.WhoWatches) } OnboardStep.WhoWatches -> WhoWatchesStep { Onboarding.go(OnboardStep.AllSet) } OnboardStep.AllSet -> AllSetStep { val tour = Onboarding.tour; Onboarding.finish(context); onDone(tour) } @@ -178,13 +178,13 @@ private fun ServerStep(onNext: () -> Unit) { Text("Where are your films and series?", color = Ink, fontFamily = Anton, fontSize = 32.sp) Text("Choose the media server they live on. Orbit only reads it, and never changes or copies anything.", color = Muted, fontSize = 15.sp) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 16.dp)) { - listOf(Triple("Emby", Color(0xFF52B54B), true), Triple("Plex", Color(0xFFE5A00D), false), Triple("Jellyfin", Color(0xFFAA5CC3), false), Triple("Silo", Color(0xFF45B8F5), false)) + listOf(Triple("Emby", Color(0xFF52B54B), true), Triple("Plex", Color(0xFFE5A00D), false), Triple("Jellyfin", Color(0xFFAA5CC3), true), Triple("Silo", Color(0xFF45B8F5), false)) .forEachIndexed { i, (name, colour, ready) -> var focused by remember { mutableStateOf(false) } val corner = RoundedCornerShape(12.dp) Column(Modifier.size(180.dp, 200.dp).then(if (i == 0) Modifier.focusRequester(first) else Modifier).onFocusChanged { focused = it.isFocused } .background(if (focused) Navy else Panel, corner).border(if (focused) 3.dp else 0.dp, if (focused) Yellow else Color.Transparent, corner) - .clickable { if (ready) onNext() else note = "$name is coming soon. For now, Orbit works with Emby." }.padding(14.dp), + .clickable { if (ready) onNext() else note = "$name is coming soon. For now, Orbit works with Emby and Jellyfin." }.padding(14.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { // A plain server mark in its colour: our own drawing, not the product's logo. Canvas(Modifier.size(70.dp, 64.dp)) { @@ -194,7 +194,7 @@ private fun ServerStep(onNext: () -> Unit) { } } Text(name, color = if (ready) Ink else Muted, fontFamily = Anton, fontSize = 24.sp) - Text(if (ready) "Sign in with your Emby account" else "Coming soon", color = Muted, fontSize = 11.sp, textAlign = TextAlign.Center) + Text(if (ready) "Sign in with your $name account" else "Coming soon", color = Muted, fontSize = 11.sp, textAlign = TextAlign.Center) } } } @@ -207,7 +207,7 @@ private fun TourSignedIn(onNext: () -> Unit) { val go = remember { FocusRequester() } LaunchedEffect(Unit) { go.requestFocus() } Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { - Text("Sign in to Emby", color = Ink, fontFamily = Anton, fontSize = 32.sp) + Text("Sign in to your server", color = Ink, fontFamily = Anton, fontSize = 32.sp) Text("A new box signs in here with an Emby account. This box is already signed in, so the tour moves on.", color = Muted, fontSize = 15.sp, modifier = Modifier.padding(8.dp)) OrbitButton("Next", Modifier.width(200.dp).focusRequester(go), onClick = onNext) } diff --git a/android/app/src/test/java/tv/orbit/classic/EmbyServerTest.kt b/android/app/src/test/java/tv/orbit/classic/EmbyServerTest.kt index a5ed3a9..779daac 100644 --- a/android/app/src/test/java/tv/orbit/classic/EmbyServerTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/EmbyServerTest.kt @@ -37,11 +37,13 @@ class EmbyServerTest { assertTrue(PlaybackUrls.isProvider(URI("https://emby.example.com:443/emby/Videos/1/stream"))) } - @Test fun checkServerAcceptsEmbyOnly() { + @Test fun checkServerAcceptsEmbyAndJellyfin() { fun check(status: Int, body: String) = EmbyClient { EmbyReply(status, body) }.checkServer() assertEquals("Home", check(200, """{"Id":"abc","Version":"4.8.0","ServerName":"Home"}""")) assertEquals("Emby", check(200, """{"Id":"abc","Version":"4.8.0"}""")) - for ((status, body) in listOf(404 to "", 200 to "", 200 to """{"Version":"4.8"}""", 200 to """{"Id":"a","Version":"10.9","ProductName":"Jellyfin Server"}""")) { + // Jellyfin too, since Orbit speaks to it (6 November 2026). + assertEquals("Jellyfin", check(200, """{"Id":"a","Version":"10.11.9","ProductName":"Jellyfin Server"}""")) + for ((status, body) in listOf(404 to "", 200 to "", 200 to """{"Version":"4.8"}""")) { try { check(status, body); fail("Accepted $body") } catch (_: EmbyCheckException) { } } } diff --git a/android/app/src/test/java/tv/orbit/classic/JellyfinLiveTest.kt b/android/app/src/test/java/tv/orbit/classic/JellyfinLiveTest.kt new file mode 100644 index 0000000..70a43d2 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/JellyfinLiveTest.kt @@ -0,0 +1,70 @@ +package tv.orbit.classic + +import org.junit.After +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** + * Orbit against a real Jellyfin server (Paul, 6 November 2026), run on this computer with a small made-up library: + * ORBIT_JELLYFIN=http://172.17.0.2:8096 ORBIT_JELLYFIN_ACCOUNT= ./gradlew :app:testDebugUnitTest --tests '*JellyfinLiveTest*' + * The same code the box runs: sign-in, the catalogue, a channel read, the Drive-In's curated films and a stream. + */ +class JellyfinLiveTest { + @After fun reset() { CuratedDriveInEvents.list = emptyList(); CuratedDriveInEvents.copies = emptyMap(); EmbyServer.origin = EmbyServer.DEFAULT } + + private class MemoryCatalogue : CatalogueSink { + val records = mutableListOf(); var next = 0L; var finished = false + override fun position(userId: String) = next + override fun write(userId: String, records: List, nextStart: Long, total: Long?, finished: Boolean) { this.records += records; next = nextStart; this.finished = finished } + fun idsByImdb(imdbs: Collection) = imdbs.map { it.lowercase() }.associateWith { imdb -> + records.filter { it.type == "Movie" && it.providerIds.entries.any { (k, v) -> k.equals("Imdb", true) && v.lowercase() == imdb } }.map { it.id } }.filterValues { it.isNotEmpty() } + } + + @Test fun orbitWorksWithJellyfin() { + val origin = System.getenv("ORBIT_JELLYFIN")?.let(EmbyServer::normalise) + val account = System.getenv("ORBIT_JELLYFIN_ACCOUNT")?.let(::File) + assumeTrue(origin != null && account != null && account.isFile) + val fields = account!!.readLines().filter { '=' in it }.associate { it.substringBefore('=') to it.substringAfter('=') } + EmbyServer.origin = origin!! + val transport = HttpsEmbyTransport("orbit-live-test", origin) + assertTrue(EmbyClient(transport).checkServer().isNotBlank()) + assertEquals(ServerKind.Jellyfin, transport.kind) + val session = EmbyClient(transport).connect(fields.getValue("user"), fields.getValue("password")) + + // A channel: Paradox finds Buffy, its three episodes playable. + val store = ScheduleStore(kotlin.io.path.createTempDirectory("jellyfin").toFile()) + val catalogue = MemoryCatalogue() + session.importCatalogue(catalogue, CATALOGUE_PAGES_FULL_RUN) + assertTrue(catalogue.finished); assertTrue(catalogue.records.any { it.title == "Buffy the Vampire Slayer" }) + val scheduler = ChannelScheduler(store, copies = catalogue::idsByImdb, original = false) + val paradox = SmartChannels.single { it.name == "Paradox" } + scheduler.setEnabled(session.accountId, paradox.number, true) + val loop = scheduler.seedLoop(session, session.accountId, paradox) + assertEquals(listOf("Buffy the Vampire Slayer"), store.loadPool(session.accountId, paradox.number)!!.series.map { it.title }) + assertTrue(scheduler.topUp(session, session.accountId, paradox, loop).programmes > 0) + + // The Drive-In: Universal's three films found by IMDb ID and verified; Hammer isn't in this library. + assertTrue(CuratedDriveInEvents.refresh(catalogue::idsByImdb)) + assertEquals(listOf("universal"), CuratedDriveInEvents.list.map { it.key }) + val universal = CuratedDriveInEvents.list.single() + val checks = session.readFilmChecks(universal.films.map { it to null }) + assertTrue(checks.values.map { it.reason }.toString(), checks.values.all { it.film != null }) + + // A stream: prepared as the player does, then the first bytes read with the headers it sends. + val episode = store.loadPlan(session.accountId, paradox.number)!!.entries.first().media.item + session.rememberScheduledItems(listOf(episode)) + val plan = session.prepare(episode) + assertTrue(plan.url, plan.url.startsWith(origin)) + val connection = URL(session.mediaUrl(plan.url)).openConnection() as HttpURLConnection + session.mediaHeaders(plan.url).forEach { (k, v) -> connection.setRequestProperty(k, v) } + connection.setRequestProperty("Range", "bytes=0-1023") + assertTrue("stream ${connection.responseCode}", connection.responseCode in listOf(200, 206)) + assertTrue(connection.inputStream.use { it.readNBytes(1024) }.size > 100) + connection.disconnect() + assertTrue(session.close()) + } +}