Type in your own Emby server address at sign-in
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m49s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m41s

Ported from release 0.108.40. Sign-in gains a Server address field (https only, checked as Emby, saved with the sign-in);
boxes signed in before keep the original server; playback's provider check matches host and port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 02:03:14 +00:00
co-authored by Claude Opus 5.5
parent 968ac2cb2f
commit 458d6ebe13
8 changed files with 148 additions and 10 deletions
@@ -17,7 +17,8 @@ internal fun interface EmbyTransport { fun execute(request: EmbyRequest): EmbyRe
/** Network boundary: fixed HTTPS origin, standard TLS checks, no redirects or response logging. */
internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUID().toString()) : EmbyTransport {
companion object {
const val SERVER = "https://bk2.appboxmanager.xyz"
/** The server in use: see [EmbyServer]. */
val SERVER get() = EmbyServer.origin
const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
const val MAX_IMAGE_BYTES = 6 * 1024 * 1024
internal fun readBounded(stream: InputStream): String {
@@ -90,6 +91,18 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
/** One explicit sign-in and one bounded metadata page; ownership passes to the sample screen. */
internal class EmbyClient(private val transport: EmbyTransport = HttpsEmbyTransport()) {
/**
* Before any password goes to a typed-in address: it must answer as an Emby server. Returns the server's own name. Jellyfin
* answers the same question, so it's turned away by name until Orbit supports it.
*/
fun checkServer(): String {
val reply = transport.execute(EmbyRequest("GET", "/System/Info/Public"))
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" }
}
fun connect(username: String, password: String): EmbySampleSession {
if (username.isBlank() || username.length > 256 || password.isEmpty() || password.length > 1024)
throw EmbyCheckException("Enter your username and password to check the connection.")
@@ -69,6 +69,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var restoreFailures by remember { mutableIntStateOf(0) }
var saveTicket by remember { mutableLongStateOf(savedStore.ticket()) }
var username by remember { mutableStateOf("") }
var serverText by remember { mutableStateOf(EmbyServer.origin) }
// Deliberately not rememberSaveable: credentials never enter saved instance state.
val password = remember { TextFieldState() }
var busy by remember { mutableStateOf(false) }
@@ -183,6 +184,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
if (saved?.identity?.token != null && session == null) {
val slotTicket = sessionSlot.ticket()
val resumed = withContext(Dispatchers.IO) {
// The server this sign-in was saved with.
EmbyServer.origin = saved.origin
val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId)).resume(saved)
if (!sessionSlot.attach(active, slotTicket)) { active.detach(); throw CancellationException() }
active
@@ -620,7 +623,22 @@ 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 = Color.White, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Text(HttpsEmbyTransport.SERVER, color = Muted, fontSize = 14.sp)
// The server's address (release essentials, 6 November 2026): typed in, HTTPS only.
Text("Server address", color = Muted, fontSize = 14.sp)
var serverFocused by remember { mutableStateOf(false) }
BasicTextField(value = serverText, onValueChange = { if (it.length <= 255) serverText = it.replace("\n", "").trim() },
enabled = !busy && !restoring, singleLine = true, textStyle = TextStyle(color = Color.White, fontSize = 18.sp),
cursorBrush = SolidColor(Yellow),
keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().onFocusChanged { serverFocused = it.isFocused }
.background(Panel).border(2.dp, if (serverFocused) Yellow else Blue)
.onPreviewKeyEvent { event ->
if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP) keyboard?.show()
true
} else false
}.padding(12.dp))
Text("For example https://emby.example.com or https://myserver.example.com:8920", color = Muted, fontSize = 11.sp)
Text("Username", color = Muted, fontSize = 14.sp)
var userFocused by remember { mutableStateOf(false) }
BasicTextField(value = username, onValueChange = { if (it.length <= 256) username = it.replace("\n", "") },
@@ -658,6 +676,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
if (!ready) return@OrbitButton
val loginName = username.trim()
val secret = password.text.toString()
val server = EmbyServer.normalise(serverText)
if (server == null) { message = "Enter your server's https:// address, like https://emby.example.com."; return@OrbitButton }
clearPassword(); keyboard?.hide(); focus.clearFocus()
closeSession()
val ticket = sessionSlot.ticket()
@@ -667,6 +687,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
job = scope.launch {
try {
val connected = withContext(Dispatchers.IO) {
// It must answer as an Emby server before the password goes anywhere.
val previous = EmbyServer.origin
EmbyServer.origin = server
try { EmbyClient().checkServer() } catch (e: Exception) { EmbyServer.origin = previous; throw e }
val opened = EmbyClient().connect(loginName, secret)
if (!sessionSlot.attach(opened, ticket)) { opened.close(); throw CancellationException() }
opened
@@ -14,8 +14,11 @@ internal class EmbyPlaybackPlan(val item: SampleItem, val url: String, val sourc
val playSessionId: String, val subtitles: List<PlaybackSubtitle>, val durationMs: Long? = item.durationMs)
internal object PlaybackUrls {
private val origin = URI(HttpsEmbyTransport.SERVER)
fun isProvider(uri: URI) = uri.scheme.equals("https", true) && uri.host.equals(origin.host, true) && (uri.port == -1 || uri.port == 443)
fun isProvider(uri: URI): Boolean {
val origin = URI(EmbyServer.origin)
val port = if (origin.port == -1) 443 else origin.port
return uri.scheme.equals("https", true) && uri.host.equals(origin.host, true) && (if (uri.port == -1) 443 else uri.port) == port
}
fun resolve(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/", token: String = ""): String {
try {
val uri = URI(base).resolve(value)
@@ -0,0 +1,35 @@
package tv.orbit.classic
import java.net.URI
/**
* Which Emby server Orbit talks to (release essentials, Paul, 6 November 2026). It used to be fixed in the app; now it's typed in
* at sign-in and saved with the sign-in, so anyone can use Orbit with their own server. The security rules stay as they were:
* HTTPS only, standard certificate checks, no redirects, and every request goes to exactly this origin. A plain-HTTP server on
* the home network is a separate, later change, because media from private addresses is refused by design.
*/
internal object EmbyServer {
/** Paul's server: what a box signed in before addresses could be typed was always using. */
const val DEFAULT = "https://bk2.appboxmanager.xyz"
@Volatile var origin: String = DEFAULT
val host: String get() = URI(origin).host
val port: Int get() = URI(origin).port
/**
* The address someone typed, as an origin Orbit will use (`https://host` or `https://host:port`), or null if it isn't one.
* A bare host gets https://. Anything after the host other than a trailing slash or /emby is refused, as are user names,
* queries and plain http.
*/
fun normalise(typed: String): String? {
val text = typed.trim().let { if ("://" in it) it else "https://$it" }
if (text.length > 255 || text.any { it.isWhitespace() || it.isISOControl() }) return null
val uri = runCatching { URI(text) }.getOrNull() ?: return null
if (!uri.scheme.equals("https", true) || uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) return null
val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() && it.matches(Regex("[a-z0-9.\\-\\[\\]:]+")) } ?: return null
val path = uri.rawPath.orEmpty().trimEnd('/')
if (path.isNotEmpty() && !path.equals("/emby", true)) return null
val port = uri.port
if (port != -1 && port !in 1..65535) return null
return "https://$host" + if (port == -1 || port == 443) "" else ":$port"
}
}
@@ -89,6 +89,7 @@ private fun PlaybackTest(itemId: String, done: () -> Unit) {
try {
withContext(Dispatchers.IO) {
val saved = RememberedConnectionStore.get(context).load() ?: throw EmbyCheckException("No saved connection.")
EmbyServer.origin = saved.origin
val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId)).resume(saved)
session = active
val item = SampleItem("Playback test", "Episode", null, "", itemId)
@@ -8,9 +8,11 @@ internal class RememberedIdentity(val userId: String, val token: String?, val de
internal class RememberedState(val identity: RememberedIdentity, val sample: EmbySample,
val matches: List<SeriesMatches>, val approved: Set<String>, val episodes: List<SeriesEpisodes>?,
val orbitOne: SampleChannel?, val toonami: SampleChannel?, val selectedNumber: Int,
val afterDark: SampleChannel? = null, val darkMovies: List<AfterDarkMovie> = emptyList(), val approvedDark: Set<String> = emptySet(), val extra: List<SampleChannel> = emptyList()) {
val afterDark: SampleChannel? = null, val darkMovies: List<AfterDarkMovie> = emptyList(), val approvedDark: Set<String> = emptySet(), val extra: List<SampleChannel> = emptyList(),
/** The Emby server this sign-in belongs to. */
val origin: String = EmbyServer.origin) {
fun withoutToken() = RememberedState(RememberedIdentity(identity.userId, null, identity.deviceId), sample,
matches, approved, episodes, orbitOne, toonami, selectedNumber, afterDark, darkMovies, approvedDark, extra)
matches, approved, episodes, orbitOne, toonami, selectedNumber, afterDark, darkMovies, approvedDark, extra, origin)
}
internal object RememberedStateCodec {
@@ -24,7 +26,7 @@ internal object RememberedStateCodec {
.put("entries", arr(it.lineup.map { m -> JSONObject().put("item", item(m.item)).put("source", m.sourceId).put("duration", m.durationMs) })) } ?: JSONObject.NULL
fun encode(state: RememberedState): ByteArray {
val i = state.identity
val root = JSONObject().put("schema", 1).put("origin", HttpsEmbyTransport.SERVER)
val root = JSONObject().put("schema", 1).put("origin", state.origin)
.put("user", i.userId).put("token", i.token ?: JSONObject.NULL).put("device", i.deviceId)
.put("sample", arr(state.sample.items.map(::item))).put("total", state.sample.total ?: JSONObject.NULL)
.put("matches", arr(state.matches.map { m -> JSONObject().put("requested", m.requested).put("more", m.moreAvailable).put("candidates", arr(m.candidates.map(::candidate))) }))
@@ -66,7 +68,9 @@ internal object RememberedStateCodec {
}
fun decode(bytes: ByteArray): RememberedState {
require(bytes.size <= MAX_BYTES)
val o = JSONObject(bytes.toString(Charsets.UTF_8)); require(o.getInt("schema") == 1 && o.getString("origin") == HttpsEmbyTransport.SERVER)
val o = JSONObject(bytes.toString(Charsets.UTF_8)); require(o.getInt("schema") == 1)
// Any server typed in at sign-in, as long as it's still a valid HTTPS origin exactly as saved.
val origin = o.getString("origin").also { require(EmbyServer.normalise(it) == it) }
val token = if (o.isNull("token")) null else o.getString("token").also { require(it.isNotEmpty() && it.length <= 4096 && it.all { c -> c.code in 33..126 }) }
val identity = RememberedIdentity(id(o, "user"), token, id(o, "device"))
val sample = EmbySample(list(o, "sample", 20).map(::readItem), if (o.isNull("total")) null else o.getLong("total").also { require(it >= 0) })
@@ -104,6 +108,6 @@ internal object RememberedStateCodec {
require(extra.map { it.number }.distinct().size == extra.size)
val selected = o.optInt("selectedChannel", o.getInt("selected")).let { if (o.optInt("numbering", 1) >= 2) it else OldChannelNumbers[it] ?: it }
require(SmartChannels.any { it.number == selected })
return RememberedState(identity, sample, matches, approved, episodes, one, toonami, selected, dark, darkMovies, approvedDark, extra)
return RememberedState(identity, sample, matches, approved, episodes, one, toonami, selected, dark, darkMovies, approvedDark, extra, origin)
}
}
@@ -0,0 +1,58 @@
package tv.orbit.classic
import org.junit.After
import org.junit.Assert.*
import org.junit.Test
import java.net.URI
class EmbyServerTest {
@After fun reset() { EmbyServer.origin = EmbyServer.DEFAULT }
@Test fun typedAddressesBecomeHttpsOrigins() {
assertEquals("https://emby.example.com", EmbyServer.normalise("emby.example.com"))
assertEquals("https://emby.example.com", EmbyServer.normalise(" HTTPS://Emby.Example.com/ "))
assertEquals("https://emby.example.com", EmbyServer.normalise("https://emby.example.com/emby/"))
assertEquals("https://emby.example.com", EmbyServer.normalise("https://emby.example.com:443"))
assertEquals("https://emby.example.com:8920", EmbyServer.normalise("emby.example.com:8920"))
}
@Test fun unsafeOrOddAddressesAreRefused() {
listOf("", "http://emby.example.com", "ftp://emby.example.com", "https://user:pw@emby.example.com", "https://emby.example.com/?a=1",
"https://emby.example.com/#x", "https://emby.example.com/web/index.html", "https://emby example.com", "https://emby.example.com:99999")
.forEach { assertNull(it, EmbyServer.normalise(it)) }
}
@Test fun savedSignInKeepsItsServer() {
val original = EmbyServerFixtures.state("https://emby.example.com:8920")
val restored = RememberedStateCodec.decode(RememberedStateCodec.encode(original))
assertEquals("https://emby.example.com:8920", restored.origin)
}
@Test fun providerMatchesHostAndPort() {
EmbyServer.origin = "https://emby.example.com:8920"
assertTrue(PlaybackUrls.isProvider(URI("https://emby.example.com:8920/emby/Videos/1/stream")))
assertFalse(PlaybackUrls.isProvider(URI("https://emby.example.com/emby/Videos/1/stream")))
assertFalse(PlaybackUrls.isProvider(URI("https://cdn.example.com:8920/v")))
EmbyServer.origin = "https://emby.example.com"
assertTrue(PlaybackUrls.isProvider(URI("https://emby.example.com:443/emby/Videos/1/stream")))
}
@Test fun checkServerAcceptsEmbyOnly() {
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"}""")) {
try { check(status, body); fail("Accepted $body") } catch (_: EmbyCheckException) { }
}
}
}
private object EmbyServerFixtures {
fun state(origin: String): RememberedState {
val series = SeriesCandidate("fixture-series", "Fixture series", 1996, "TV-PG")
val episodes = SeriesEpisodes(series, List(2) { EpisodeCandidate("episode-$it", "Fixture $it", 1, it + 1, 60_000) }, false)
val channel = SampleChannel(episodes.episodes.map { ScheduledMedia(episodeSampleItem(series, it), "source-${it.id}", 60_000) }, "Toonami", 601)
return RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(emptyList(), 0),
listOf(SeriesMatches(ToonamiSearches.first(), listOf(series), false)), setOf(series.id), listOf(episodes), null, channel, 601, origin = origin)
}
}
@@ -36,7 +36,7 @@ class RememberedStateTest {
}
@Test fun wrongSchemaOriginAndOversizedRecordsAreRejected() {
val original = JSONObject(String(RememberedStateCodec.encode(state())))
for (changed in listOf(JSONObject(original.toString()).put("schema", 999), JSONObject(original.toString()).put("origin", "https://other.example"),
for (changed in listOf(JSONObject(original.toString()).put("schema", 999), JSONObject(original.toString()).put("origin", "http://other.example"),
JSONObject(original.toString()).put("user", "../bad"), JSONObject(original.toString()).put("approved", JSONArray().put("unmatched")))) {
try { RememberedStateCodec.decode(changed.toString().toByteArray()); fail("Invalid saved record accepted") }
catch (_: IllegalArgumentException) { }