From de6d3a921f6c13d3395fa6543a51dbf0694c80ec Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 16:53:14 +0200 Subject: [PATCH] What 505 real channels showed that 20 demo ones could not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/src/main/java/dev/castarr/tv/AppState.kt | 3 +- .../main/java/dev/castarr/tv/MainActivity.kt | 60 +++++++++------ .../java/dev/castarr/tv/data/TeamFilters.kt | 27 ++++++- .../main/java/dev/castarr/tv/data/Xmltv.kt | 25 +++++++ .../main/java/dev/castarr/tv/ui/LiveCells.kt | 24 +++--- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 14 +++- tests/unit/DuplicateProgrammesTest.kt | 74 +++++++++++++++++++ tests/unit/FixtureTest.kt | 45 +++++++++++ 8 files changed, 232 insertions(+), 40 deletions(-) create mode 100644 tests/unit/DuplicateProgrammesTest.kt diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index 8b50118..5b6af6c 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -94,7 +94,8 @@ class AppState( /** Everything this channel still shows today, for the day plan. */ fun upcomingToday(channel: Channel): List { 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). */ diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index fa53b0b..7093b30 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -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 } } diff --git a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt index 74d09ee..500dcbf 100644 --- a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt +++ b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt @@ -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. */ diff --git a/app/src/main/java/dev/castarr/tv/data/Xmltv.kt b/app/src/main/java/dev/castarr/tv/data/Xmltv.kt index 26206bb..64e9db9 100644 --- a/app/src/main/java/dev/castarr/tv/data/Xmltv.kt +++ b/app/src/main/java/dev/castarr/tv/data/Xmltv.kt @@ -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, + toleranceMs: Long = DUPLICATE_TOLERANCE_MS, + ): List { + val kept = mutableListOf() + 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?, diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt b/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt index 97806b7..68eeed5 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt @@ -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, diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index a97d8a8..7fe55df 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -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) } diff --git a/tests/unit/DuplicateProgrammesTest.kt b/tests/unit/DuplicateProgrammesTest.kt new file mode 100644 index 0000000..ccd37bc --- /dev/null +++ b/tests/unit/DuplicateProgrammesTest.kt @@ -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(), XmltvParser.collapseDuplicates(emptyList())) + } +} diff --git a/tests/unit/FixtureTest.kt b/tests/unit/FixtureTest.kt index 0b70530..9a81885 100644 --- a/tests/unit/FixtureTest.kt +++ b/tests/unit/FixtureTest.kt @@ -56,4 +56,49 @@ class FixtureTest { 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"), + ) + } }