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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>? 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
|
||||
}
|
||||
|
||||
@@ -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<String, String> = 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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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<String, String> {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 "<html>", 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 "<html>", 200 to """{"Version":"4.8"}""")) {
|
||||
try { check(status, body); fail("Accepted $body") } catch (_: EmbyCheckException) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=<file with user= and password=> ./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<CatalogueRecord>(); var next = 0L; var finished = false
|
||||
override fun position(userId: String) = next
|
||||
override fun write(userId: String, records: List<CatalogueRecord>, nextStart: Long, total: Long?, finished: Boolean) { this.records += records; next = nextStart; this.finished = finished }
|
||||
fun idsByImdb(imdbs: Collection<String>) = 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user