What 505 real channels showed that 20 demo ones could not
All checks were successful
Build TV app / build (push) Successful in 42s

The favourite count sat at zero while the backend already had two: the rail
read the favourites off the StateFlow inside the composable instead of
collecting it, so it never recomposed — a regression from making favourites
work for both source types. The list collects again.

Real EPG titles broke the fixture shortener in both directions. "BL: VfB
Stuttgart - Saison 25-26" became a fixture against a season, and
"… FC Bayern München - VfB Stuttgart - 1. Halbzeit" grew a third team; a
season, a matchday, a half or a year is now rejected, and a second dash ends
the away side. The kick-off chip also gave way in the row: the line below
already carries the time, and "Bayer Leverkusen - VfB Stuttgart" needed the
width to fit at all.

Merged provider EPGs list the same programme several times, minutes apart —
the day plan showed The Big Bang Theory three times and two entries marked
as running at once. XmltvParser.collapseDuplicates folds a repeat into the
entry whose slot it falls inside.

The login code expired before anyone could confirm it: Authentik hands out a
minute, and the TV dropped back to step one without a word. It fetches a
fresh code instead, up to eight times.

Tested against the household's own Dispatcharr through api.tv.beckm4nn.net.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 16:53:14 +02:00
parent 4cb7a6efdb
commit de6d3a921f
8 changed files with 232 additions and 40 deletions

View File

@@ -94,7 +94,8 @@ class AppState(
/** Everything this channel still shows today, for the day plan. */
fun upcomingToday(channel: Channel): List<dev.castarr.tv.data.Programme> {
val now = System.currentTimeMillis()
return upcoming(channel, dev.castarr.tv.data.TeamFilters.windowEnd(now) - now)
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
return dev.castarr.tv.data.XmltvParser.collapseDuplicates(upcoming(channel, window))
}
/** Digits typed on the remote's number pad (channel switching). */

View File

@@ -194,33 +194,44 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
lifecycleScope.launch {
try {
state.auth.fetchServerConfig(url.trim())
val session = state.auth.startDeviceFlow()
state.welcomeUserCode = session.userCode
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
val deadline = System.currentTimeMillis() + session.expiresInSeconds * 1000L
while (System.currentTimeMillis() < deadline) {
delay(session.intervalSeconds * 1000L)
when (state.auth.poll(session)) {
is dev.castarr.tv.auth.DeviceAuth.PollResult.Success -> {
state.welcomePhase = AppState.WelcomePhase.DONE
server.broadcastSetupDone()
state.setMode(AppState.SourceMode.DISPATCHARR)
delay(1500)
state.screen = AppState.Screen.LIVE
return@launch
// Identity providers hand out short-lived codes — Authentik
// defaults to a minute, which is not enough time to walk to
// the phone. A fresh code is fetched automatically instead of
// dropping the viewer back to step one without a word.
repeat(LOGIN_CODE_ROUNDS) { round ->
val session = state.auth.startDeviceFlow()
state.welcomeUserCode = session.userCode
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
if (round > 0) {
server.broadcastToast("Neuer Anmeldecode: ${session.userCode}")
}
val deadline =
System.currentTimeMillis() + session.expiresInSeconds * 1000L
while (System.currentTimeMillis() < deadline) {
delay(session.intervalSeconds * 1000L)
when (state.auth.poll(session)) {
is dev.castarr.tv.auth.DeviceAuth.PollResult.Success -> {
state.welcomePhase = AppState.WelcomePhase.DONE
server.broadcastSetupDone()
state.setMode(AppState.SourceMode.DISPATCHARR)
delay(1500)
state.screen = AppState.Screen.LIVE
return@launch
}
dev.castarr.tv.auth.DeviceAuth.PollResult.Denied -> {
server.broadcastToast("Anmeldung abgelehnt — bitte erneut versuchen")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
state.welcomeUserCode = ""
return@launch
}
dev.castarr.tv.auth.DeviceAuth.PollResult.Pending -> Unit
}
dev.castarr.tv.auth.DeviceAuth.PollResult.Denied -> {
server.broadcastToast("Anmeldung abgelehnt — bitte erneut versuchen")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
state.welcomeUserCode = ""
return@launch
}
dev.castarr.tv.auth.DeviceAuth.PollResult.Pending -> Unit
}
}
server.broadcastToast("Anmeldecode abgelaufen — bitte erneut versuchen")
server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
state.welcomeUserCode = ""
} catch (e: Exception) {
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")
server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung")
@@ -401,5 +412,8 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
/** How often a fresh login code is fetched before giving up. */
const val LOGIN_CODE_ROUNDS = 8
}
}

View File

@@ -178,14 +178,35 @@ object TeamFilters {
// Trailing detail after the pairing ("…, 1. Runde", "… | live").
val pairing = body.substringBefore(",").substringBefore(" | ").trim()
if (!pairing.contains(separator)) return null
val (home, away) = pairing.split(separator, limit = 2)
if (home.isBlank() || away.isBlank()) return null
return "${home.trim()} - ${away.trim()}"
val (rawHome, rawAway) = pairing.split(separator, limit = 2)
val home = rawHome.trim()
// A second dash usually starts a detail, not a third team:
// "… - VfB Stuttgart - 1. Halbzeit".
val away = rawAway.substringBefore(" - ").trim()
if (home.length < 3 || away.length < 3) return null
// A season, a matchday or a half is not an opponent — real EPG data
// is full of "VfB Stuttgart - Saison 25-26".
if (isNotATeam(home) || isNotATeam(away)) return null
return "$home - $away"
}
private fun isNotATeam(side: String): Boolean {
val s = side.lowercase()
if (NON_TEAM_WORDS.any { s.contains(it) }) return true
return YEAR_LIKE.containsMatchIn(s)
}
/** Dash variants German EPG data uses between the two teams. */
private val SEPARATORS = listOf(" - ", " ", "", " vs. ", " vs ", " gegen ")
private val NON_TEAM_WORDS = listOf(
"saison", "halbzeit", "spieltag", "runde", "highlights", "höhepunkte",
"konferenz", "vereinsprofil", "rückblick", "zusammenfassung", "magazin",
)
/** "25-26", "2025/26", "1899" — never an opponent on its own. */
private val YEAR_LIKE = Regex("""\b\d{2}\s*[-/]\s*\d{2}\b|\b(19|20)\d{2}\b""")
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
/** Clubs grouped by division, in the order the picker shows them. */

View File

@@ -105,6 +105,31 @@ object XmltvParser {
}
}
/**
* Collapses the same programme listed several times.
*
* Merged provider EPGs carry a title more than once with starts a few
* minutes apart — the day plan showed "The Big Bang Theory" three times
* in half an hour and marked two entries as running at once. The first
* entry wins and swallows the repeats that start inside its slot.
*/
fun collapseDuplicates(
programmes: List<Programme>,
toleranceMs: Long = DUPLICATE_TOLERANCE_MS,
): List<Programme> {
val kept = mutableListOf<Programme>()
programmes.sortedBy { it.start }.forEach { candidate ->
val previous = kept.lastOrNull { it.title.equals(candidate.title, ignoreCase = true) }
val repeats = previous != null &&
(candidate.start - previous.start <= toleranceMs || candidate.start < previous.stop)
if (!repeats) kept.add(candidate)
}
return kept
}
/** Two starts this close with the same title are the same broadcast. */
const val DUPLICATE_TOLERANCE_MS = 30 * 60 * 1000L
/** Programmes overlapping [from]..[to], in broadcast order. */
fun programmesIn(
programmes: List<Programme>?,

View File

@@ -167,18 +167,24 @@ internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier)
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(12.dp))
Text(
if (running) "läuft" else "ab ${formatClock(programme.start)}",
color = if (running) CastarrColors.accent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal,
)
// Only "läuft" earns space up here. The kick-off is already in
// the line below, and the fixture needs every pixel: real titles
// like "Bayer Leverkusen - VfB Stuttgart" were still cut off.
if (running) {
Spacer(Modifier.width(12.dp))
Text(
"läuft",
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
Spacer(Modifier.height(6.dp))
Text(
"${formatClock(programme.start)}${formatClock(programme.stop)}" +
(if (running) "" else "ab ") +
"${formatClock(programme.start)}${formatClock(programme.stop)}" +
if (hit.further > 0) " · +${hit.further} weitere" else "",
color = CastarrColors.faint,
fontFamily = AppFont,

View File

@@ -53,11 +53,17 @@ import dev.castarr.tv.AppState
fun LiveScreen(state: AppState) {
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
val dispatcharrChannels by state.dispatcharr.channels.collectAsStateWithLifecycle()
val favorites by state.dispatcharr.favorites.collectAsStateWithLifecycle()
// Backend favourites have to be *collected*, not read off the flow:
// reading the value inside a composable never recomposes, so the rail
// sat at zero while the backend already knew better.
val backendFavorites by state.dispatcharr.favorites.collectAsStateWithLifecycle()
// Re-render Now/Next when a new EPG arrives.
val genericEpg by state.source.epgUpdatedAt.collectAsStateWithLifecycle()
val dispatcharrEpg by state.dispatcharr.epgUpdatedAt.collectAsStateWithLifecycle()
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR
fun isFavorite(channel: dev.castarr.tv.playlist.Channel) =
if (isDispatcharr) channel.backendId in backendFavorites else state.isFavorite(channel)
val favoriteCount = if (isDispatcharr) backendFavorites.size else state.favoriteCount()
val epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
val groups = remember(allChannels) {
@@ -85,7 +91,7 @@ fun LiveScreen(state: AppState) {
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
val channels = when {
activeTeam != null -> activeMatches.map { it.first }
state.favoritesOnly -> allChannels.filter { state.isFavorite(it) }
state.favoritesOnly -> allChannels.filter { isFavorite(it) }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels
}
@@ -208,7 +214,7 @@ fun LiveScreen(state: AppState) {
item {
GroupItem(
label = "★ Favoriten",
count = state.favoriteCount(),
count = favoriteCount,
selected = state.favoritesOnly && state.activeTeam == null,
modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
@@ -333,7 +339,7 @@ fun LiveScreen(state: AppState) {
playing = state.currentChannel?.url == channel.url,
// All rows are favorites in the favorites view — the
// star only carries meaning elsewhere.
favorite = !state.favoritesOnly && state.isFavorite(channel),
favorite = !state.favoritesOnly && isFavorite(channel),
epgStamp = epgStamp,
onLongClick = { state.toggleFavorite(channel) },
) { state.play(channel) }