What 505 real channels showed that 20 demo ones could not
All checks were successful
Build TV app / build (push) Successful in 42s
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:
@@ -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). */
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>?,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
74
tests/unit/DuplicateProgrammesTest.kt
Normal file
74
tests/unit/DuplicateProgrammesTest.kt
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user