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. */ /** Everything this channel still shows today, for the day plan. */
fun upcomingToday(channel: Channel): List<dev.castarr.tv.data.Programme> { fun upcomingToday(channel: Channel): List<dev.castarr.tv.data.Programme> {
val now = System.currentTimeMillis() 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). */ /** Digits typed on the remote's number pad (channel switching). */

View File

@@ -194,11 +194,20 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
state.auth.fetchServerConfig(url.trim()) state.auth.fetchServerConfig(url.trim())
// 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() val session = state.auth.startDeviceFlow()
state.welcomeUserCode = session.userCode state.welcomeUserCode = session.userCode
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
server.broadcastLoginLink(session.verificationUriComplete, session.userCode) server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
val deadline = System.currentTimeMillis() + session.expiresInSeconds * 1000L if (round > 0) {
server.broadcastToast("Neuer Anmeldecode: ${session.userCode}")
}
val deadline =
System.currentTimeMillis() + session.expiresInSeconds * 1000L
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
delay(session.intervalSeconds * 1000L) delay(session.intervalSeconds * 1000L)
when (state.auth.poll(session)) { when (state.auth.poll(session)) {
@@ -219,8 +228,10 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
dev.castarr.tv.auth.DeviceAuth.PollResult.Pending -> Unit 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.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
state.welcomeUserCode = ""
} 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")
@@ -401,5 +412,8 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
const val SEEK_STEP_SECONDS = 10L const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L const val DIGIT_COMMIT_MS = 1_800L
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L 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"). // Trailing detail after the pairing ("…, 1. Runde", "… | live").
val pairing = body.substringBefore(",").substringBefore(" | ").trim() val pairing = body.substringBefore(",").substringBefore(" | ").trim()
if (!pairing.contains(separator)) return null if (!pairing.contains(separator)) return null
val (home, away) = pairing.split(separator, limit = 2) val (rawHome, rawAway) = pairing.split(separator, limit = 2)
if (home.isBlank() || away.isBlank()) return null val home = rawHome.trim()
return "${home.trim()} - ${away.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. */ /** Dash variants German EPG data uses between the two teams. */
private val SEPARATORS = listOf(" - ", " ", "", " vs. ", " vs ", " gegen ") 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 } fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
/** Clubs grouped by division, in the order the picker shows them. */ /** 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. */ /** Programmes overlapping [from]..[to], in broadcast order. */
fun programmesIn( fun programmesIn(
programmes: List<Programme>?, programmes: List<Programme>?,

View File

@@ -167,17 +167,23 @@ internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier)
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
) )
// 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)) Spacer(Modifier.width(12.dp))
Text( Text(
if (running) "läuft" else "ab ${formatClock(programme.start)}", "läuft",
color = if (running) CastarrColors.accent else CastarrColors.muted, color = CastarrColors.accent,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal, fontWeight = FontWeight.SemiBold,
) )
} }
}
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
(if (running) "" else "ab ") +
"${formatClock(programme.start)}${formatClock(programme.stop)}" + "${formatClock(programme.start)}${formatClock(programme.stop)}" +
if (hit.further > 0) " · +${hit.further} weitere" else "", if (hit.further > 0) " · +${hit.further} weitere" else "",
color = CastarrColors.faint, color = CastarrColors.faint,

View File

@@ -53,11 +53,17 @@ import dev.castarr.tv.AppState
fun LiveScreen(state: AppState) { fun LiveScreen(state: AppState) {
val genericChannels by state.source.channels.collectAsStateWithLifecycle() val genericChannels by state.source.channels.collectAsStateWithLifecycle()
val dispatcharrChannels by state.dispatcharr.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. // Re-render Now/Next when a new EPG arrives.
val genericEpg by state.source.epgUpdatedAt.collectAsStateWithLifecycle() val genericEpg by state.source.epgUpdatedAt.collectAsStateWithLifecycle()
val dispatcharrEpg by state.dispatcharr.epgUpdatedAt.collectAsStateWithLifecycle() val dispatcharrEpg by state.dispatcharr.epgUpdatedAt.collectAsStateWithLifecycle()
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR 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 epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
val groups = remember(allChannels) { 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 teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
val channels = when { val channels = when {
activeTeam != null -> activeMatches.map { it.first } 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 } state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels else -> allChannels
} }
@@ -208,7 +214,7 @@ fun LiveScreen(state: AppState) {
item { item {
GroupItem( GroupItem(
label = "★ Favoriten", label = "★ Favoriten",
count = state.favoriteCount(), count = favoriteCount,
selected = state.favoritesOnly && state.activeTeam == null, selected = state.favoritesOnly && state.activeTeam == null,
modifier = intoList.then( modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
@@ -333,7 +339,7 @@ fun LiveScreen(state: AppState) {
playing = state.currentChannel?.url == channel.url, playing = state.currentChannel?.url == channel.url,
// All rows are favorites in the favorites view — the // All rows are favorites in the favorites view — the
// star only carries meaning elsewhere. // star only carries meaning elsewhere.
favorite = !state.favoritesOnly && state.isFavorite(channel), favorite = !state.favoritesOnly && isFavorite(channel),
epgStamp = epgStamp, epgStamp = epgStamp,
onLongClick = { state.toggleFavorite(channel) }, onLongClick = { state.toggleFavorite(channel) },
) { state.play(channel) } ) { state.play(channel) }

View File

@@ -0,0 +1,74 @@
package dev.castarr.tv.data
import org.junit.Assert.assertEquals
import org.junit.Test
class DuplicateProgrammesTest {
private fun p(startMin: Int, stopMin: Int, title: String) =
Programme(startMin * 60_000L, stopMin * 60_000L, title)
/** The real case: one channel, one episode, three EPG entries. */
@Test
fun `repeats a few minutes apart collapse into one`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1090, 1140, "The Big Bang Theory"),
p(1095, 1145, "The Big Bang Theory"),
p(1120, 1170, "The Big Bang Theory"),
)
)
assertEquals(1, collapsed.size)
assertEquals(1090 * 60_000L, collapsed.first().start)
}
@Test
fun `a genuine repeat later in the evening survives`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1090, 1140, "Tagesschau"),
p(1300, 1320, "Tagesschau"),
)
)
assertEquals(2, collapsed.size)
}
@Test
fun `different programmes at the same time both stay`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1090, 1140, "Sportschau"),
p(1092, 1142, "Tagesschau"),
)
)
assertEquals(2, collapsed.size)
}
@Test
fun `a repeat inside the first slot collapses even beyond the tolerance`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1000, 1200, "Fußball: Konferenz"),
p(1100, 1300, "Fußball: Konferenz"),
)
)
assertEquals(1, collapsed.size)
}
@Test
fun `an unsorted list is handled in time order`() {
val collapsed = XmltvParser.collapseDuplicates(
listOf(
p(1095, 1145, "Two and a Half Men"),
p(1090, 1140, "Two and a Half Men"),
)
)
assertEquals(1, collapsed.size)
assertEquals(1090 * 60_000L, collapsed.first().start)
}
@Test
fun `an empty list stays empty`() {
assertEquals(emptyList<Programme>(), XmltvParser.collapseDuplicates(emptyList()))
}
}

View File

@@ -56,4 +56,49 @@ class FixtureTest {
TeamFilters.fixture("3. Liga: Rot-Weiss Essen - Preußen Münster"), TeamFilters.fixture("3. Liga: Rot-Weiss Essen - Preußen Münster"),
) )
} }
// --- Titel aus echten EPG-Daten (Sky, DAZN, ran) ----------------------
@Test
fun `a real Sky title yields both clubs`() {
assertEquals(
"Bayer Leverkusen - VfB Stuttgart",
TeamFilters.fixture(
"BL: Bayer Leverkusen - VfB Stuttgart, tipico Topspiel der Woche, 16. Spieltag"
),
)
}
/** The one that gave "VfB Stuttgart - Saison 25-26" as a fixture. */
@Test
fun `a season is not an opponent`() {
assertNull(TeamFilters.fixture("BL: VfB Stuttgart - Saison 25-26"))
assertNull(TeamFilters.fixture("BL: Vereinsprofil VfB Stuttgart"))
}
@Test
fun `a trailing half is dropped, not treated as a third team`() {
assertEquals(
"Bundesliga Eröffnungsspiel FC Bayern München - VfB Stuttgart",
TeamFilters.fixture(
"ran Fußball: Bundesliga Eröffnungsspiel FC Bayern München - VfB Stuttgart - 1. Halbzeit"
),
)
}
@Test
fun `youth teams keep their suffix`() {
assertEquals(
"VfB Stuttgart U19 - SC Freiburg U19",
TeamFilters.fixture("Live DFB-Pokal Junioren: VfB Stuttgart U19 - SC Freiburg U19, 2. Runde"),
)
}
@Test
fun `a live prefix falls away with the competition`() {
assertEquals(
"VfB Stuttgart - Hamburger SV",
TeamFilters.fixture("LIVE: VfB Stuttgart - Hamburger SV"),
)
}
} }