diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6cb9559..cf69573 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,6 +97,9 @@ dependencies { implementation("com.google.zxing:core:3.5.3") implementation("io.coil-kt:coil-compose:2.7.0") testImplementation("junit:junit:4.13.2") + // Android ships a stub org.json for unit tests that throws on every call; + // the real one lets the backend parsing be tested without a device. + testImplementation("org.json:json:20240303") } // Full JDK for javac via toolchain (host may only have a JRE); resolved by diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index 5b6af6c..5d8f2cd 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -182,6 +182,14 @@ class AppState( var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE) var welcomeUserCode by mutableStateOf("") + /** + * True once the login code has been replaced because the old one ran out. + * The identity provider hands out short-lived codes; swapping the digits + * on screen without a word leaves whoever is typing them wondering why + * the phone says no. + */ + var welcomeCodeRenewed by mutableStateOf(false) + /** Plain-language fullscreen error state (issue #13). */ var appError by mutableStateOf(AppError.NONE) @@ -401,6 +409,7 @@ class AppState( appError = AppError.NONE welcomePhase = WelcomePhase.WAIT_PHONE welcomeUserCode = "" + welcomeCodeRenewed = false screen = Screen.WELCOME } diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index 7093b30..b549eb0 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -165,7 +165,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { .put("channel", channel.name) .put("url", channel.url) .put("group", channel.group) - .put("title", hit.programme.title) + // Same shortening as on the TV: the phone showed + // "BL: Bayer Leverkusen - VfB Stuttgart, tipico + // Topspiel der Woche, 16. Spieltag" in full. + .put( + "title", + dev.castarr.tv.data.TeamFilters.fixture(hit.programme.title) + ?: hit.programme.title, + ) .put("start", hit.programme.start) .put("stop", hit.programme.stop) .put("further", hit.further) @@ -201,6 +208,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { repeat(LOGIN_CODE_ROUNDS) { round -> val session = state.auth.startDeviceFlow() state.welcomeUserCode = session.userCode + state.welcomeCodeRenewed = round > 0 state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN server.broadcastLoginLink(session.verificationUriComplete, session.userCode) if (round > 0) { @@ -232,6 +240,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen") state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE state.welcomeUserCode = "" + state.welcomeCodeRenewed = false } catch (e: Exception) { android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}") server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung") diff --git a/app/src/main/java/dev/castarr/tv/data/DispatcharrJson.kt b/app/src/main/java/dev/castarr/tv/data/DispatcharrJson.kt new file mode 100644 index 0000000..0689165 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/data/DispatcharrJson.kt @@ -0,0 +1,119 @@ +package dev.castarr.tv.data + +import dev.castarr.tv.playlist.Channel +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** + * Everything the Dispatcharr backend sends, turned into the app's own types. + * + * Split out of [DispatcharrRepository] so the parsing can be tested without a + * server: the whole backend path had no coverage, and the first run against a + * real instance turned up three bugs in an afternoon. + */ +object DispatcharrJson { + + /** `{"results": [...]}` from the paginated API, or a bare array. */ + fun paginatedResults(body: String): JSONArray = + runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body) + + /** Next page URL of a paginated response, or null on the last one. */ + fun nextPage(body: String): String? = + runCatching { JSONObject(body).optString("next") }.getOrNull() + ?.takeIf { it.isNotEmpty() && it != "null" } + + fun parseGroups(body: String): Map { + val results = paginatedResults(body) + return (0 until results.length()).associate { + val obj = results.getJSONObject(it) + obj.getInt("id") to obj.optString("name") + } + } + + /** `{"channels": [1, 2]}` — the ids the signed-in user starred. */ + fun parseFavorites(body: String): Set { + val ids = runCatching { JSONObject(body).optJSONArray("channels") }.getOrNull() ?: JSONArray() + return (0 until ids.length()).map { ids.getInt(it) }.toSet() + } + + /** Output profile names; inactive ones are not offered. */ + fun parseProfiles(body: String): List { + val results = paginatedResults(body) + return (0 until results.length()).mapNotNull { + val obj = results.getJSONObject(it) + if (obj.optBoolean("is_active", true)) obj.optString("name") else null + } + } + + /** + * One page of channels. + * + * A channel without a uuid cannot be played (the proxy resolves by uuid, + * integer ids give a 404), and one hidden from output is not meant to + * show up at all — both are dropped. + */ + fun parseChannels( + body: String, + groups: Map, + streamUrl: (uuid: String) -> String, + logoUrl: (id: Int) -> String, + ): List { + val results = paginatedResults(body) + val list = mutableListOf() + for (i in 0 until results.length()) { + val obj = results.getJSONObject(i) + val uuid = obj.optString("uuid") + if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue + list.add( + Channel( + name = obj.optString("effective_name").ifEmpty { obj.optString("name") }, + url = streamUrl(uuid), + group = groups[obj.optInt("channel_group_id")].orEmpty(), + logo = (obj.optInt("effective_logo_id").takeIf { it > 0 } + ?: obj.optInt("logo_id").takeIf { it > 0 }) + ?.let(logoUrl) + .orEmpty(), + tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") }, + backendId = obj.getInt("id"), + streamKey = uuid, + ) + ) + } + return list + } + + /** + * The EPG grid, keyed by tvg-id. The envelope is `{"data": [...]}`, but + * `results` and a bare array are tolerated — the fork has shipped all + * three at some point. + */ + fun parseEpgGrid(body: String): Map> { + val root = runCatching { JSONObject(body) }.getOrNull() + val results = root?.optJSONArray("data") + ?: root?.optJSONArray("results") + ?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") } + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val map = HashMap>() + for (i in 0 until results.length()) { + val obj = results.getJSONObject(i) + val tvgId = obj.optString("tvg_id") + if (tvgId.isEmpty()) continue + val start = parseIso(obj.optString("start_time"), format) + val stop = parseIso(obj.optString("end_time"), format) + if (start == 0L || stop <= start) continue + map.getOrPut(tvgId) { mutableListOf() } + .add(Programme(start, stop, obj.optString("title"))) + } + map.values.forEach { it.sortBy(Programme::start) } + return map + } + + private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching { + format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L + }.getOrDefault(0L) +} diff --git a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt index 1871548..ae68c5c 100644 --- a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt +++ b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt @@ -11,12 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray -import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone /** * Channel source backed by the Dispatcharr fork's Bearer API: channels, @@ -124,45 +120,23 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) { channels.value = channels.value.map { it.copy(url = streamUrl(it.streamKey)) } } - private suspend fun fetchGroups(token: String): Map { - val body = request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token) - val results = paginatedResults(body) - return (0 until results.length()).associate { - val obj = results.getJSONObject(it) - obj.getInt("id") to obj.optString("name") - } - } + private suspend fun fetchGroups(token: String): Map = + DispatcharrJson.parseGroups( + request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token) + ) private suspend fun fetchChannels(token: String, groups: Map): List { val list = mutableListOf() var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500" while (url != null && list.size < 10_000) { val body = request(url, "GET", token) - val json = runCatching { JSONObject(body) }.getOrNull() - val results = json?.optJSONArray("results") ?: JSONArray(body) - for (i in 0 until results.length()) { - val obj = results.getJSONObject(i) - val id = obj.getInt("id") - val uuid = obj.optString("uuid") - if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue - list.add( - Channel( - name = obj.optString("effective_name").ifEmpty { obj.optString("name") }, - url = streamUrl(uuid), - group = groups[obj.optInt("channel_group_id")].orEmpty(), - // The list serializer only carries logo ids; the - // cache endpoint serves the image without auth. - logo = (obj.optInt("effective_logo_id").takeIf { it > 0 } - ?: obj.optInt("logo_id").takeIf { it > 0 }) - ?.let { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" } - .orEmpty(), - tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") }, - backendId = id, - streamKey = uuid, - ) - ) - } - url = json?.optString("next")?.takeIf { it.isNotEmpty() && it != "null" } + list += DispatcharrJson.parseChannels( + body = body, + groups = groups, + streamUrl = ::streamUrl, + logoUrl = { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" }, + ) + url = DispatcharrJson.nextPage(body) } list.sortBy { it.name.lowercase() } return list @@ -170,54 +144,22 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) { private suspend fun refreshFavorites(token: String) { val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token) - val ids = JSONObject(body).optJSONArray("channels") ?: JSONArray() - favorites.value = (0 until ids.length()).map { ids.getInt(it) }.toSet() + favorites.value = DispatcharrJson.parseFavorites(body) } private suspend fun refreshProfiles(token: String) { val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token) - val results = paginatedResults(body) - profiles.value = (0 until results.length()).mapNotNull { - val obj = results.getJSONObject(it) - if (obj.optBoolean("is_active", true)) obj.optString("name") else null - } + profiles.value = DispatcharrJson.parseProfiles(body) } private suspend fun refreshEpg(token: String) { status.value = "loading_epg" val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token) - // Envelope is {"data": [...]}; tolerate bare arrays and "results" too. - val root = runCatching { JSONObject(body) }.getOrNull() - val results = root?.optJSONArray("data") - ?: root?.optJSONArray("results") - ?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") } - val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - val map = HashMap>() - for (i in 0 until results.length()) { - val obj = results.getJSONObject(i) - val tvgId = obj.optString("tvg_id") - if (tvgId.isEmpty()) continue - val start = parseIso(obj.optString("start_time"), format) - val stop = parseIso(obj.optString("end_time"), format) - if (start == 0L || stop <= start) continue - map.getOrPut(tvgId) { mutableListOf() } - .add(Programme(start, stop, obj.optString("title"))) - } - map.values.forEach { it.sortBy(Programme::start) } - programmesByTvgId = map + programmesByTvgId = DispatcharrJson.parseEpgGrid(body) epgUpdatedAt.value = System.currentTimeMillis() status.value = "" } - private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching { - format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L - }.getOrDefault(0L) - - private fun paginatedResults(body: String): JSONArray = - runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body) - private suspend fun request(url: String, method: String, token: String): String = withContext(Dispatchers.IO) { val connection = URL(url).openConnection() as HttpURLConnection diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index f6ab5b9..fa50db5 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.foundation.focusGroup import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -80,7 +81,9 @@ fun SettingsScreen(state: AppState) { // club menus arrived, and the ten-foot rule (CONTEXT.md) allows no // scrolling outside the channel list. Column( - Modifier.weight(1f), + Modifier + .weight(1f) + .focusGroup(), verticalArrangement = Arrangement.spacedBy(16.dp), ) { SettingsCard("Konto") { @@ -203,7 +206,9 @@ fun SettingsScreen(state: AppState) { Spacer(Modifier.width(16.dp)) Column( - Modifier.weight(1f), + Modifier + .weight(1f) + .focusGroup(), verticalArrangement = Arrangement.spacedBy(16.dp), ) { SettingsCard("App") { @@ -259,7 +264,11 @@ fun SettingsScreen(state: AppState) { Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) { + Column( + Modifier + .weight(1f) + .focusGroup() + ) { SettingsCard("Handy-Fernbedienung") { val address = remember(pairingEpoch) { Pairing.lanAddress() } if (!state.remoteAvailable) { diff --git a/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt b/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt index 63d3a05..c2b7ed5 100644 --- a/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt @@ -61,8 +61,18 @@ fun WelcomeScreen(state: AppState) { AppState.WelcomePhase.WAIT_PHONE -> Unit AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…") AppState.WelcomePhase.WAIT_LOGIN -> Status( - if (state.welcomeUserCode.isEmpty()) "Warte auf die Anmeldung am Handy…" - else "Anmeldung am Handy bestätigen · Code ${state.welcomeUserCode}" + when { + state.welcomeUserCode.isEmpty() -> + "Warte auf die Anmeldung am Handy…" + // Saying so beats swapping the digits in silence + // while somebody is typing them. + state.welcomeCodeRenewed -> + "Der alte Code war abgelaufen — neuer Code " + + state.welcomeUserCode + else -> + "Anmeldung am Handy bestätigen · Code " + + state.welcomeUserCode + } ) AppState.WelcomePhase.DONE -> Status("Angemeldet! Lade Sender…") } diff --git a/tests/dispatcharr-checkliste.md b/tests/dispatcharr-checkliste.md new file mode 100644 index 0000000..3caf048 --- /dev/null +++ b/tests/dispatcharr-checkliste.md @@ -0,0 +1,78 @@ +# Dispatcharr-Pfad: Ablauf vor einem Release + +Was hier steht, deckt kein Test ab: Anmeldung, Backend-Abruf und alles, was +ein echtes Konto braucht. `tests/smoke.sh` hilft dabei nicht — er +deinstalliert die App und damit die Anmeldung. + +Dauer: etwa zehn Minuten. Am Emulator oder auf einem echten Fernseher. + +## Vorbereitet + +- Emulator läuft (`tests/helpers/emulator.sh start`) +- Aktuelles APK installiert +- Jemand ist erreichbar, der die Anmeldung im Identity Provider bestätigen + kann — der Gerätecode lebt derzeit 60 Sekunden (siehe Issue #23) + +## 1. Anmelden + +- [ ] App zurücksetzen: `adb shell pm clear dev.castarr.tv` +- [ ] App starten — der Willkommensbildschirm zeigt QR, Adresse **und** den + vierstelligen Kopplungscode +- [ ] Fernbedienung am Handy öffnen, Server eintragen +- [ ] Der Fernseher zeigt „Anmeldung am Handy bestätigen" mit Code +- [ ] Code bestätigen; der Fernseher wechselt von allein in die Senderliste +- [ ] Läuft der Code ab, holt der Fernseher selbstständig einen neuen — + er darf **nicht** kommentarlos auf Schritt 1 zurückfallen + +## 2. Senderliste + +- [ ] Die Zahl neben „Alle Sender" entspricht der Senderzahl im Backend +- [ ] Sender tragen Logos und Namen aus dem Backend +- [ ] Sender mit Programm zeigen es rechts, Sender ohne zeigen ihre Gruppe +- [ ] Die Kopfleiste zeigt **keinen** Hinweis „Server nicht erreichbar" +- [ ] Sendertasten springen zum nächsten Anfangsbuchstaben +- [ ] Rechts auf einer Senderzeile öffnet den Tagesplan; keine Sendung steht + doppelt drin, höchstens eine ist als „läuft" markiert + +## 3. Favoriten (die Stelle, an der es zuletzt still kaputt war) + +- [ ] „★ Favoriten" zeigt die Zahl, die das Backend kennt — nicht 0 +- [ ] Langer Druck auf einen Sender setzt den Stern, die Zahl steigt sofort +- [ ] Erneuter langer Druck entfernt ihn, die Zahl sinkt +- [ ] App neu starten: die Favoriten sind noch da (sie kommen vom Backend) +- [ ] Am Handy: derselbe Stern, dieselbe Zahl + +## 4. Vereinsmenüs + +- [ ] Für einen Verein mit Spiel heute steht ein Menü mit Kurznamen in der + Leiste („Stuttgart", nicht „1893") +- [ ] Läuft ein Spiel, steht „läuft" im Chip, sonst „ab HH:MM" +- [ ] In der Vereinsansicht steht die Paarung, nicht der EPG-Rohtitel — + und der Vereinsname ist vollständig zu lesen +- [ ] Ein Titel ohne Paarung („Vereinsprofil VfB Stuttgart") wird unverändert + gezeigt, nicht zu einer Begegnung verbogen + +## 5. Wiedergabe und Profile + +- [ ] Ein Sender startet und zeigt Bild +- [ ] Sendertasten hoch/runter wechseln den Sender +- [ ] Die Sender-Zurück-Taste springt zum vorherigen Sender +- [ ] Einstellungen → Stream-Qualität listet die Profile des Backends +- [ ] Ein anderes Profil auswählen, Sender neu starten — er läuft weiter + +## 6. Abmelden + +- [ ] Einstellungen → Abmelden führt zurück zum Willkommensbildschirm +- [ ] Nach erneutem Start bleibt der Willkommensbildschirm stehen + (keine Senderliste aus dem Zwischenspeicher) + +## Wenn etwas hakt + +Logcat mitlesen: + +``` +adb -s emulator-5554 logcat -s DispatcharrRepo DeviceAuth Onboarding +``` + +Fehler beim Abruf tauchen dort auf; ein stiller Fehlschlag ohne Logzeile ist +selbst ein Befund und gehört als Issue aufgeschrieben. diff --git a/tests/unit/DispatcharrJsonTest.kt b/tests/unit/DispatcharrJsonTest.kt new file mode 100644 index 0000000..a2e5c32 --- /dev/null +++ b/tests/unit/DispatcharrJsonTest.kt @@ -0,0 +1,145 @@ +package dev.castarr.tv.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Shapes taken from the household's own Dispatcharr fork. The backend path + * had no coverage at all, and the first run against a real instance found + * three bugs — these are the shapes that produced them. + */ +class DispatcharrJsonTest { + + private fun streamUrl(uuid: String) = "https://tv.example/proxy/ts/stream/$uuid" + private fun logoUrl(id: Int) = "https://tv.example/api/channels/logos/$id/cache/" + + private val groups = mapOf(1 to "Free TV / HD+", 2 to "Sky Sport") + + @Test + fun `a paginated page yields its channels`() { + val body = """ + {"count": 2, "next": null, "results": [ + {"id": 12, "uuid": "aaa", "name": "Das Erste", "effective_name": "Das Erste HD", + "channel_group_id": 1, "effective_logo_id": 7, "effective_tvg_id": "ard.de"}, + {"id": 13, "uuid": "bbb", "name": "ZDF HD", "channel_group_id": 1, "logo_id": 8} + ]} + """.trimIndent() + val channels = DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl) + assertEquals(2, channels.size) + val first = channels.first() + assertEquals("Das Erste HD", first.name) + assertEquals("Free TV / HD+", first.group) + assertEquals("ard.de", first.tvgId) + assertEquals(12, first.backendId) + assertEquals("https://tv.example/proxy/ts/stream/aaa", first.url) + assertEquals("https://tv.example/api/channels/logos/7/cache/", first.logo) + // Without an effective name the plain one is used, same for the logo. + assertEquals("ZDF HD", channels[1].name) + assertEquals("https://tv.example/api/channels/logos/8/cache/", channels[1].logo) + } + + /** The proxy resolves by uuid; an integer id gives a 404. */ + @Test + fun `a channel without uuid is dropped`() { + val body = """{"results": [{"id": 1, "uuid": "", "name": "Kaputt"}]}""" + assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty()) + } + + @Test + fun `a channel hidden from output is dropped`() { + val body = """ + {"results": [{"id": 1, "uuid": "aaa", "name": "Intern", "hidden_from_output": true}]} + """.trimIndent() + assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty()) + } + + @Test + fun `an unknown group leaves the group empty instead of failing`() { + val body = """{"results": [{"id": 1, "uuid": "aaa", "name": "X", "channel_group_id": 99}]}""" + assertEquals("", DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).first().group) + } + + @Test + fun `a bare array works like a paginated page`() { + val body = """[{"id": 5, "uuid": "ccc", "name": "Bar"}]""" + assertEquals(1, DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).size) + } + + @Test + fun `the next page is followed only while there is one`() { + assertEquals( + "https://tv.example/api/channels/channels/?page=2", + DispatcharrJson.nextPage("""{"next": "https://tv.example/api/channels/channels/?page=2"}"""), + ) + assertNull(DispatcharrJson.nextPage("""{"next": null}""")) + assertNull(DispatcharrJson.nextPage("""{"next": ""}""")) + assertNull(DispatcharrJson.nextPage("""[{"id": 1}]""")) + } + + @Test + fun `groups come back as id to name`() { + val body = """{"results": [{"id": 1, "name": "Free TV / HD+"}, {"id": 2, "name": "Sky Sport"}]}""" + assertEquals(groups, DispatcharrJson.parseGroups(body)) + } + + @Test + fun `favourites are the ids under channels`() { + assertEquals(setOf(46, 118), DispatcharrJson.parseFavorites("""{"channels": [46, 118]}""")) + assertEquals(emptySet(), DispatcharrJson.parseFavorites("""{"channels": []}""")) + // A user who never starred anything gets an envelope without the key. + assertEquals(emptySet(), DispatcharrJson.parseFavorites("""{}""")) + } + + @Test + fun `inactive output profiles are not offered`() { + val body = """ + {"results": [ + {"name": "raw", "is_active": true}, + {"name": "alt", "is_active": false}, + {"name": "default"} + ]} + """.trimIndent() + assertEquals(listOf("raw", "default"), DispatcharrJson.parseProfiles(body)) + } + + @Test + fun `the epg grid is keyed by tvg id and sorted`() { + val body = """ + {"data": [ + {"tvg_id": "ard.de", "title": "Tagesschau", + "start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}, + {"tvg_id": "ard.de", "title": "Sportschau", + "start_time": "2026-08-26T17:00:00Z", "end_time": "2026-08-26T18:00:00Z"} + ]} + """.trimIndent() + val grid = DispatcharrJson.parseEpgGrid(body) + assertEquals(listOf("Sportschau", "Tagesschau"), grid.getValue("ard.de").map { it.title }) + } + + @Test + fun `grid entries without a usable time or id are skipped`() { + val body = """ + {"data": [ + {"tvg_id": "", "title": "Ohne Sender", + "start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}, + {"tvg_id": "zdf.de", "title": "Kaputte Zeit", + "start_time": "kein Datum", "end_time": "2026-08-26T18:15:00Z"}, + {"tvg_id": "zdf.de", "title": "Ende vor Start", + "start_time": "2026-08-26T18:15:00Z", "end_time": "2026-08-26T18:00:00Z"} + ]} + """.trimIndent() + assertTrue(DispatcharrJson.parseEpgGrid(body).isEmpty()) + } + + /** The fork has shipped "data", "results" and a bare array over time. */ + @Test + fun `all three grid envelopes are accepted`() { + val entry = """{"tvg_id": "ard.de", "title": "X", + "start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}""" + listOf("""{"data": [$entry]}""", """{"results": [$entry]}""", """[$entry]""").forEach { body -> + assertEquals(1, DispatcharrJson.parseEpgGrid(body).getValue("ard.de").size) + } + } +}