Type in your own Emby server address at sign-in
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:
@@ -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) { }
|
||||
|
||||
Reference in New Issue
Block a user