Work through the 1.0 milestone: focus, backend tests, remote, login code
All checks were successful
Build TV app / build (push) Successful in 53s

#20 Each settings column is a focus group now. The middle column is shorter
than the left, so "down" past its last row used to search wider and land
back in the left column — "Erweitert" was only reachable by going right,
down, left.

#22 The whole backend path had no coverage, which is why an afternoon
against the real instance found three bugs. Parsing moved out of
DispatcharrRepository into DispatcharrJson and is covered by twelve tests
built from the shapes that instance actually returns: pagination, channels
without a uuid or hidden from output, effective vs plain names and logos,
favourites without the key, inactive output profiles, and all three EPG
envelopes the fork has shipped. Android's stub org.json throws in unit
tests, so the real implementation is on the test classpath.
What no test can reach is written down in tests/dispatcharr-checkliste.md.

#24 The remote's channel tab was only ever read in source. Driven through
the DevTools protocol against the real backend it lists 503 channels with
logos and EPG, the search filters, the club chip switches view, and a star
survives the round trip to the backend and back — set and unset. It also
showed the phone still spelling out "BL: Bayer Leverkusen - VfB Stuttgart,
tipico Topspiel der Woche, 16. Spieltag" where the TV shortens to the
fixture; the payload shortens now too.

#23 The TV says "Der alte Code war abgelaufen — neuer Code 566071643"
instead of swapping the digits without a word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 19:33:29 +02:00
parent de6d3a921f
commit d269618ebf
9 changed files with 402 additions and 78 deletions

View File

@@ -97,6 +97,9 @@ dependencies {
implementation("com.google.zxing:core:3.5.3") implementation("com.google.zxing:core:3.5.3")
implementation("io.coil-kt:coil-compose:2.7.0") implementation("io.coil-kt:coil-compose:2.7.0")
testImplementation("junit:junit:4.13.2") 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 // Full JDK for javac via toolchain (host may only have a JRE); resolved by

View File

@@ -182,6 +182,14 @@ class AppState(
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE) var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
var welcomeUserCode by mutableStateOf("") 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). */ /** Plain-language fullscreen error state (issue #13). */
var appError by mutableStateOf(AppError.NONE) var appError by mutableStateOf(AppError.NONE)
@@ -401,6 +409,7 @@ class AppState(
appError = AppError.NONE appError = AppError.NONE
welcomePhase = WelcomePhase.WAIT_PHONE welcomePhase = WelcomePhase.WAIT_PHONE
welcomeUserCode = "" welcomeUserCode = ""
welcomeCodeRenewed = false
screen = Screen.WELCOME screen = Screen.WELCOME
} }

View File

@@ -165,7 +165,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("channel", channel.name) .put("channel", channel.name)
.put("url", channel.url) .put("url", channel.url)
.put("group", channel.group) .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("start", hit.programme.start)
.put("stop", hit.programme.stop) .put("stop", hit.programme.stop)
.put("further", hit.further) .put("further", hit.further)
@@ -201,6 +208,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
repeat(LOGIN_CODE_ROUNDS) { round -> repeat(LOGIN_CODE_ROUNDS) { round ->
val session = state.auth.startDeviceFlow() val session = state.auth.startDeviceFlow()
state.welcomeUserCode = session.userCode state.welcomeUserCode = session.userCode
state.welcomeCodeRenewed = round > 0
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
server.broadcastLoginLink(session.verificationUriComplete, session.userCode) server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
if (round > 0) { if (round > 0) {
@@ -232,6 +240,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen") server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
state.welcomeUserCode = "" state.welcomeUserCode = ""
state.welcomeCodeRenewed = false
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}") android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")
server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung") server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung")

View File

@@ -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<Int, String> {
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<Int> {
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<String> {
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<Int, String>,
streamUrl: (uuid: String) -> String,
logoUrl: (id: Int) -> String,
): List<Channel> {
val results = paginatedResults(body)
val list = mutableListOf<Channel>()
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<String, List<Programme>> {
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<String, MutableList<Programme>>()
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)
}

View File

@@ -11,12 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL 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, * 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)) } channels.value = channels.value.map { it.copy(url = streamUrl(it.streamKey)) }
} }
private suspend fun fetchGroups(token: String): Map<Int, String> { private suspend fun fetchGroups(token: String): Map<Int, String> =
val body = request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token) DispatcharrJson.parseGroups(
val results = paginatedResults(body) request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
return (0 until results.length()).associate { )
val obj = results.getJSONObject(it)
obj.getInt("id") to obj.optString("name")
}
}
private suspend fun fetchChannels(token: String, groups: Map<Int, String>): List<Channel> { private suspend fun fetchChannels(token: String, groups: Map<Int, String>): List<Channel> {
val list = mutableListOf<Channel>() val list = mutableListOf<Channel>()
var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500" var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500"
while (url != null && list.size < 10_000) { while (url != null && list.size < 10_000) {
val body = request(url, "GET", token) val body = request(url, "GET", token)
val json = runCatching { JSONObject(body) }.getOrNull() list += DispatcharrJson.parseChannels(
val results = json?.optJSONArray("results") ?: JSONArray(body) body = body,
for (i in 0 until results.length()) { groups = groups,
val obj = results.getJSONObject(i) streamUrl = ::streamUrl,
val id = obj.getInt("id") logoUrl = { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" },
val uuid = obj.optString("uuid") )
if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue url = DispatcharrJson.nextPage(body)
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.sortBy { it.name.lowercase() } list.sortBy { it.name.lowercase() }
return list return list
@@ -170,54 +144,22 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
private suspend fun refreshFavorites(token: String) { private suspend fun refreshFavorites(token: String) {
val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token) val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token)
val ids = JSONObject(body).optJSONArray("channels") ?: JSONArray() favorites.value = DispatcharrJson.parseFavorites(body)
favorites.value = (0 until ids.length()).map { ids.getInt(it) }.toSet()
} }
private suspend fun refreshProfiles(token: String) { private suspend fun refreshProfiles(token: String) {
val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token) val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token)
val results = paginatedResults(body) profiles.value = DispatcharrJson.parseProfiles(body)
profiles.value = (0 until results.length()).mapNotNull {
val obj = results.getJSONObject(it)
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
}
} }
private suspend fun refreshEpg(token: String) { private suspend fun refreshEpg(token: String) {
status.value = "loading_epg" status.value = "loading_epg"
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token) val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
// Envelope is {"data": [...]}; tolerate bare arrays and "results" too. programmesByTvgId = DispatcharrJson.parseEpgGrid(body)
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<String, MutableList<Programme>>()
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
epgUpdatedAt.value = System.currentTimeMillis() epgUpdatedAt.value = System.currentTimeMillis()
status.value = "" 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 = private suspend fun request(url: String, method: String, token: String): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val connection = URL(url).openConnection() as HttpURLConnection val connection = URL(url).openConnection() as HttpURLConnection

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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 // club menus arrived, and the ten-foot rule (CONTEXT.md) allows no
// scrolling outside the channel list. // scrolling outside the channel list.
Column( Column(
Modifier.weight(1f), Modifier
.weight(1f)
.focusGroup(),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
SettingsCard("Konto") { SettingsCard("Konto") {
@@ -203,7 +206,9 @@ fun SettingsScreen(state: AppState) {
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
Column( Column(
Modifier.weight(1f), Modifier
.weight(1f)
.focusGroup(),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
SettingsCard("App") { SettingsCard("App") {
@@ -259,7 +264,11 @@ fun SettingsScreen(state: AppState) {
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) { Column(
Modifier
.weight(1f)
.focusGroup()
) {
SettingsCard("Handy-Fernbedienung") { SettingsCard("Handy-Fernbedienung") {
val address = remember(pairingEpoch) { Pairing.lanAddress() } val address = remember(pairingEpoch) { Pairing.lanAddress() }
if (!state.remoteAvailable) { if (!state.remoteAvailable) {

View File

@@ -61,8 +61,18 @@ fun WelcomeScreen(state: AppState) {
AppState.WelcomePhase.WAIT_PHONE -> Unit AppState.WelcomePhase.WAIT_PHONE -> Unit
AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…") AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…")
AppState.WelcomePhase.WAIT_LOGIN -> Status( AppState.WelcomePhase.WAIT_LOGIN -> Status(
if (state.welcomeUserCode.isEmpty()) "Warte auf die Anmeldung am Handy…" when {
else "Anmeldung am Handy bestätigen · Code ${state.welcomeUserCode}" 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…") AppState.WelcomePhase.DONE -> Status("Angemeldet! Lade Sender…")
} }

View File

@@ -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.

View File

@@ -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<Int>(), DispatcharrJson.parseFavorites("""{"channels": []}"""))
// A user who never starred anything gets an envelope without the key.
assertEquals(emptySet<Int>(), 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)
}
}
}