From 7ad7f5172eedbea37b8075a58c8b7cd0343008bf Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 02:47:06 +0200 Subject: [PATCH] Club shortcut: a rail group for "where is my club on tonight" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional rail entry per club that lists every channel whose EPG mentions the club within the next three hours, with the matching kick-off instead of the usual Now/Next. Only sport and free-to-air groups are scanned (NFL excluded), so the scan stays cheap and quiet. Which clubs appear is a per-TV setting: the signed-in viewer's own club is on by default, and every club can be switched on or off under Einstellungen -> Vereinsmenüs, so a household can follow more than one. Crests are trademarks, so the icon is a plain shield in the club colours. Co-Authored-By: Claude Fable 5 --- app/build.gradle.kts | 4 +- app/src/main/java/dev/castarr/tv/AppState.kt | 50 +++++++ .../castarr/tv/data/DispatcharrRepository.kt | 6 + .../dev/castarr/tv/data/SourceRepository.kt | 17 ++- .../java/dev/castarr/tv/data/TeamFilters.kt | 85 ++++++++++++ .../main/java/dev/castarr/tv/data/Xmltv.kt | 8 ++ .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 131 +++++++++++++++++- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 34 +++++ tests/unit/TeamFiltersTest.kt | 46 ++++++ tests/unit/XmltvWindowTest.kt | 34 +++++ 10 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/dev/castarr/tv/data/TeamFilters.kt create mode 100644 tests/unit/TeamFiltersTest.kt create mode 100644 tests/unit/XmltvWindowTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6f2688..2df4882 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "dev.castarr.tv" minSdk = 26 targetSdk = 35 - versionCode = 25 - versionName = "0.9.2" + versionCode = 26 + versionName = "0.10.0" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index c98a024..debfa90 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue import dev.castarr.tv.auth.DeviceAuth import dev.castarr.tv.data.DispatcharrRepository import dev.castarr.tv.data.NowNext +import dev.castarr.tv.data.isEpgPlaceholder import dev.castarr.tv.data.SourceRepository import dev.castarr.tv.player.PlayerController import dev.castarr.tv.playlist.Channel @@ -38,6 +39,30 @@ class AppState( var isLive by mutableStateOf(false) var favoritesOnly by mutableStateOf(false) var groupFilter by mutableStateOf(null) + + /** Club shortcut currently open in the rail, if any. */ + var activeTeam by mutableStateOf(null) + + /** + * Club menus switched on for this TV. Seeded from the signed-in viewer's + * own club; every club can be toggled on in the settings, so a household + * can watch for more than one. + */ + var enabledTeams by mutableStateOf(loadEnabledTeams()) + private set + + private fun loadEnabledTeams(): Set { + val stored = prefs.getStringSet("teams", null) + if (stored != null) return stored.toSet() + return setOfNotNull(dev.castarr.tv.data.TeamFilters.defaultKeyFor(auth.username)) + } + + fun toggleTeam(key: String) { + enabledTeams = + if (key in enabledTeams) enabledTeams - key else enabledTeams + key + prefs.edit().putStringSet("teams", enabledTeams).apply() + if (activeTeam == key && key !in enabledTeams) activeTeam = null + } var audioTracks by mutableStateOf>(emptyList()) private set private var lastNowTitle: String? = null @@ -132,6 +157,29 @@ class AppState( SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel) } + private fun upcoming(channel: Channel, windowMs: Long) = when (sourceMode) { + SourceMode.GENERIC -> source.upcoming(channel, windowMs) + SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs) + } + + /** + * Channels showing the viewer's club within the scanned window, paired + * with the programme that matched — earliest kick-off first, so whatever + * is running right now sits on top. + */ + fun teamMatches(key: String): List> { + val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList() + val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS + return activeChannels() + .filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) } + .mapNotNull { channel -> + upcoming(channel, window) + .firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) } + ?.let { channel to it } + } + .sortedBy { it.second.start } + } + fun refreshActive() { when (sourceMode) { SourceMode.GENERIC -> source.refresh() @@ -168,6 +216,8 @@ class AppState( } fun startOnboarding() { + prefs.edit().remove("teams").apply() + activeTeam = null auth.logout() appError = AppError.NONE welcomePhase = WelcomePhase.WAIT_PHONE diff --git a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt index 9b95c2f..bc316eb 100644 --- a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt +++ b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt @@ -71,6 +71,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) { fun nowNext(channel: Channel): NowNext = XmltvParser.nowNext(programmesByTvgId[channel.tvgId]) + /** Programmes of this channel between now and now + [windowMs]. */ + fun upcoming(channel: Channel, windowMs: Long): List { + val now = System.currentTimeMillis() + return XmltvParser.programmesIn(programmesByTvgId[channel.tvgId], now, now + windowMs) + } + fun toggleFavorite(channel: Channel) { if (channel.backendId == 0) return scope.launch { diff --git a/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt b/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt index 2f29493..b3dc1d7 100644 --- a/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt +++ b/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt @@ -82,13 +82,20 @@ class SourceRepository(context: Context) { status.value = "" } - /** Now/Next for an M3U channel: match tvg-id first, then name. */ - fun nowNext(channel: Channel): NowNext { - val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] } - val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] } - return XmltvParser.nowNext(byName) + /** Programmes of this channel between now and now + [windowMs]. */ + fun upcoming(channel: Channel, windowMs: Long): List { + val now = System.currentTimeMillis() + return XmltvParser.programmesIn(programmesFor(channel), now, now + windowMs) } + private fun programmesFor(channel: Channel): List? { + val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] } + return direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] } + } + + /** Now/Next for an M3U channel: match tvg-id first, then name. */ + fun nowNext(channel: Channel): NowNext = XmltvParser.nowNext(programmesFor(channel)) + private fun loadCachedChannels() { runCatching { val cached = prefs.getString("channels_cache", null) ?: return diff --git a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt new file mode 100644 index 0000000..f7fc9fa --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt @@ -0,0 +1,85 @@ +package dev.castarr.tv.data + +import androidx.compose.ui.graphics.Color + +/** + * Per-club shortcut to "where does my club play tonight": a rail entry that + * lists every channel whose EPG mentions the club within the next few hours. + * Club crests are trademarks, so the icon is a plain shield in the club + * colours rather than the real badge. + */ +data class TeamFilter( + val key: String, + /** Short caption next to the crest in the rail. */ + val label: String, + /** How the club is named in a sentence. */ + val name: String, + /** Full club name, used in the settings. */ + val fullName: String, + /** Lowercase needles matched against programme titles. */ + val needles: List, + val primary: Color, + val secondary: Color, +) { + fun matches(title: String): Boolean { + val haystack = title.lowercase() + return needles.any { haystack.contains(it) } + } +} + +object TeamFilters { + + /** Window scanned ahead of now. */ + const val WINDOW_MS = 3 * 60 * 60 * 1000L + + val all = listOf( + TeamFilter( + key = "hansa", + label = "FCH", + name = "Hansa", + fullName = "Hansa Rostock", + needles = listOf("hansa"), + primary = Color(0xFF0B4EA2), + secondary = Color(0xFFF2F3F5), + ), + TeamFilter( + key = "vfb", + label = "1893", + name = "dem VfB", + fullName = "VfB Stuttgart", + needles = listOf("vfb stuttgart", "vfb"), + primary = Color(0xFFE32219), + secondary = Color(0xFFF2F3F5), + ), + ) + + /** The club a viewer gets switched on by default. */ + private val defaultForUser = mapOf( + "benjamin" to "hansa", + "tobiasb" to "vfb", + ) + + /** + * Only sport and free-to-air groups are scanned. Searching all 500 + * channels would mostly turn up shopping and radio, and the club is + * never on those anyway. + */ + private val groupIncludes = listOf( + "sport", "dazn", "sky", "magenta", "prime", "free tv", "hd+", + "dyn", "del", "bundesliga", "fussball", "fußball", "at / ch", "at/ch", + ) + + /** Explicitly out of scope even though they match an include. */ + private val groupExcludes = listOf("nfl") + + fun scansGroup(group: String): Boolean { + val g = group.lowercase() + if (groupExcludes.any { g.contains(it) }) return false + return groupIncludes.any { g.contains(it) } + } + + fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key } + + fun defaultKeyFor(username: String): String? = + defaultForUser[username.trim().lowercase()] +} 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 83e71f8..26206bb 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,14 @@ object XmltvParser { } } + /** Programmes overlapping [from]..[to], in broadcast order. */ + fun programmesIn( + programmes: List?, + from: Long, + to: Long, + ): List = + programmes.orEmpty().filter { it.stop > from && it.start < to } + fun nowNext(programmes: List?, at: Long = System.currentTimeMillis()): NowNext { if (programmes.isNullOrEmpty()) return NowNext(null, null) val index = programmes.indexOfFirst { at < it.stop } 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 dd1cc3b..4a5aa1d 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -1,6 +1,7 @@ package dev.castarr.tv.ui import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,6 +30,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester @@ -71,7 +75,19 @@ fun LiveScreen(state: AppState) { val groups = remember(allChannels) { allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted() } + val teams = remember(state.enabledTeams) { + dev.castarr.tv.data.TeamFilters.all.filter { it.key in state.enabledTeams } + } + // Recomputed when the EPG or the channel list changes — scanning the + // sport groups' programmes is cheap, but not per frame. + val matchesByTeam = remember(teams, allChannels, epgStamp) { + teams.associate { it.key to state.teamMatches(it.key) } + } + val activeTeam = teams.firstOrNull { it.key == state.activeTeam } + val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty() + 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 { it.backendId in favorites } state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter } else -> allChannels @@ -128,7 +144,8 @@ fun LiveScreen(state: AppState) { verticalArrangement = Arrangement.spacedBy(2.dp), ) { item { - val selected = !state.favoritesOnly && state.groupFilter == null + val selected = !state.favoritesOnly && state.groupFilter == null && + state.activeTeam == null GroupItem( label = "Alle Sender", count = allChannels.size, @@ -138,6 +155,7 @@ fun LiveScreen(state: AppState) { ), suppressAutoSelect = { state.restorePending }, ) { + state.activeTeam = null state.favoritesOnly = false state.groupFilter = null } @@ -147,17 +165,35 @@ fun LiveScreen(state: AppState) { GroupItem( label = "★ Favoriten", count = favorites.size, - selected = state.favoritesOnly, + selected = state.favoritesOnly && state.activeTeam == null, modifier = intoList.then( if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier ), suppressAutoSelect = { state.restorePending }, ) { + state.activeTeam = null state.favoritesOnly = true state.groupFilter = null } } } + items(teams, key = { it.key }) { club -> + GroupItem( + label = club.label, + count = matchesByTeam[club.key]?.size ?: 0, + selected = state.activeTeam == club.key, + leading = { Crest(club) }, + modifier = intoList.then( + if (state.activeTeam == club.key) Modifier.focusRequester(railFocus) + else Modifier + ), + suppressAutoSelect = { state.restorePending }, + ) { + state.activeTeam = club.key + state.favoritesOnly = false + state.groupFilter = null + } + } item { Box( Modifier @@ -171,12 +207,13 @@ fun LiveScreen(state: AppState) { GroupItem( label = group, count = remember(allChannels, group) { allChannels.count { it.group == group } }, - selected = state.groupFilter == group, + selected = state.groupFilter == group && state.activeTeam == null, modifier = intoList.then( if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier ), suppressAutoSelect = { state.restorePending }, ) { + state.activeTeam = null state.favoritesOnly = false state.groupFilter = group } @@ -196,9 +233,13 @@ fun LiveScreen(state: AppState) { if (channels.isEmpty()) { item { Text( - if (state.favoritesOnly) - "Noch keine Favoriten — halte OK auf einem Sender gedrückt." - else "Diese Gruppe ist leer.", + when { + activeTeam != null -> + "In den nächsten 3 Stunden läuft nichts mit ${activeTeam.name}." + state.favoritesOnly -> + "Noch keine Favoriten — halte OK auf einem Sender gedrückt." + else -> "Diese Gruppe ist leer." + }, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 14.sp, @@ -220,6 +261,7 @@ fun LiveScreen(state: AppState) { else Modifier ), nowNext = state.nowNext(channel), + highlight = teamHits[channel.url], playing = state.currentChannel?.url == channel.url, // All rows are favorites in the favorites view — the // star only carries meaning elsewhere. @@ -241,6 +283,7 @@ private fun GroupItem( count: Int, selected: Boolean, modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, suppressAutoSelect: () -> Boolean = { false }, onSelect: () -> Unit, ) { @@ -264,6 +307,10 @@ private fun GroupItem( modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { + if (leading != null) { + leading() + Spacer(Modifier.width(10.dp)) + } Text( label, fontFamily = AppFont, @@ -289,6 +336,7 @@ private fun ChannelRow( number: Int, modifier: Modifier = Modifier, nowNext: NowNext, + highlight: dev.castarr.tv.data.Programme? = null, playing: Boolean, favorite: Boolean, epgStamp: Long, @@ -349,7 +397,13 @@ private fun ChannelRow( } } Spacer(Modifier.width(24.dp)) - EpgCell(nowNext, Modifier.weight(1f)) + if (highlight != null) { + // In the club view the matching broadcast is the point, not + // whatever happens to be running. + HighlightCell(highlight, Modifier.weight(1f)) + } else { + EpgCell(nowNext, Modifier.weight(1f)) + } if (playing) { Spacer(Modifier.width(12.dp)) Box( @@ -363,6 +417,35 @@ private fun ChannelRow( } } +/** + * Stand-in for the club badge: real crests are trademarks, so this draws a + * simple shield in the club colours instead. + */ +@Composable +private fun Crest(team: dev.castarr.tv.data.TeamFilter) { + Canvas(Modifier.size(18.dp)) { + val w = size.width + val h = size.height + val shield = Path().apply { + moveTo(w * 0.5f, 0f) + lineTo(w, h * 0.18f) + lineTo(w, h * 0.55f) + cubicTo(w, h * 0.82f, w * 0.75f, h * 0.95f, w * 0.5f, h) + cubicTo(w * 0.25f, h * 0.95f, 0f, h * 0.82f, 0f, h * 0.55f) + lineTo(0f, h * 0.18f) + close() + } + drawPath(shield, team.primary) + clipPath(shield) { + drawRect( + team.secondary, + topLeft = Offset(0f, h * 0.42f), + size = androidx.compose.ui.geometry.Size(w, h * 0.16f), + ) + } + } +} + /** Bare channel logo (they ship transparent); initials as fallback. */ @Composable private fun LogoTile(channel: Channel) { @@ -397,6 +480,40 @@ private fun LogoInitials(initials: String) { } } +/** The programme that matched the club filter, with its start time. */ +@Composable +private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Modifier = Modifier) { + val running = System.currentTimeMillis() in programme.start until programme.stop + Column(modifier) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + programme.title, + color = CastarrColors.fg, + fontFamily = AppFont, + fontSize = 14.sp, + maxLines = 1, + 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, + ) + } + Spacer(Modifier.height(6.dp)) + Text( + "${formatClock(programme.start)}–${formatClock(programme.stop)}", + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 12.sp, + ) + } +} + private fun formatClock(millis: Long): String = SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index 6f30b1e..ae2ef22 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -163,6 +163,40 @@ fun SettingsScreen(state: AppState) { } } + SettingsCard("Vereinsmenüs") { + Text( + "Zeigt eine eigene Gruppe mit allen Sendern, auf denen der " + + "Verein in den nächsten 3 Stunden läuft.", + color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp), + ) + dev.castarr.tv.data.TeamFilters.all.forEach { club -> + val on = club.key in state.enabledTeams + SettingRow( + club.fullName, + subtitle = "Erscheint als ${club.label} in der Senderliste", + trailing = { + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background( + if (on) CastarrColors.accent else CastarrColors.bg + ) + .padding(horizontal = 12.dp, vertical = 5.dp) + ) { + Text( + if (on) "An" else "Aus", + color = if (on) CastarrColors.onAccent else CastarrColors.muted, + fontFamily = AppFont, + fontSize = 12.sp, + fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal, + ) + } + }, + ) { state.toggleTeam(club.key) } + } + } + SettingsCard("App") { SettingRow( "Version ${BuildConfig.VERSION_NAME}", diff --git a/tests/unit/TeamFiltersTest.kt b/tests/unit/TeamFiltersTest.kt new file mode 100644 index 0000000..3913868 --- /dev/null +++ b/tests/unit/TeamFiltersTest.kt @@ -0,0 +1,46 @@ +package dev.castarr.tv.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TeamFiltersTest { + + private val hansa = TeamFilters.byKey("hansa")!! + private val vfb = TeamFilters.byKey("vfb")!! + + @Test + fun `matches the club regardless of case and surrounding text`() { + assertTrue(hansa.matches("3. Liga: FC Hansa Rostock - Dynamo Dresden")) + assertTrue(hansa.matches("HANSA ROSTOCK KOMPAKT")) + assertTrue(vfb.matches("Bundesliga: VfB Stuttgart - Bayern München")) + } + + @Test + fun `does not match unrelated programmes`() { + assertFalse(hansa.matches("Tagesschau")) + assertFalse(vfb.matches("Hansa Rostock - Saarbrücken")) + } + + @Test + fun `scans sport and free-to-air groups`() { + listOf("DAZN Event", "Sky Sport", "Magenta Sport", "Amazon Prime", "Free TV / HD+", "DYN Sport") + .forEach { assertTrue(it, TeamFilters.scansGroup(it)) } + } + + @Test + fun `skips NFL and unrelated groups`() { + assertFalse(TeamFilters.scansGroup("DAZN Event NFL")) + assertFalse(TeamFilters.scansGroup("Kids")) + assertFalse(TeamFilters.scansGroup("Musik")) + } + + @Test + fun `assigns each viewer their own club by default`() { + assertEquals("hansa", TeamFilters.defaultKeyFor("benjamin")) + assertEquals("vfb", TeamFilters.defaultKeyFor("TobiasB")) + assertNull(TeamFilters.defaultKeyFor("someone-else")) + } +} diff --git a/tests/unit/XmltvWindowTest.kt b/tests/unit/XmltvWindowTest.kt new file mode 100644 index 0000000..e8de2a8 --- /dev/null +++ b/tests/unit/XmltvWindowTest.kt @@ -0,0 +1,34 @@ +package dev.castarr.tv.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +class XmltvWindowTest { + + private fun p(startMin: Long, endMin: Long, title: String) = + Programme(startMin * 60_000, endMin * 60_000, title) + + private val schedule = listOf( + p(0, 60, "Läuft gerade"), + p(60, 120, "Gleich danach"), + p(150, 210, "In zweieinhalb Stunden"), + p(300, 360, "Deutlich später"), + ) + + @Test + fun `returns programmes overlapping the window`() { + val titles = XmltvParser.programmesIn(schedule, 30 * 60_000, 180 * 60_000).map { it.title } + assertEquals(listOf("Läuft gerade", "Gleich danach", "In zweieinhalb Stunden"), titles) + } + + @Test + fun `excludes programmes outside the window`() { + val titles = XmltvParser.programmesIn(schedule, 0, 60 * 60_000).map { it.title } + assertEquals(listOf("Läuft gerade"), titles) + } + + @Test + fun `handles a missing schedule`() { + assertEquals(emptyList(), XmltvParser.programmesIn(null, 0, 1_000)) + } +}