2 Commits

Author SHA1 Message Date
be-nj
7ad7f5172e Club shortcut: a rail group for "where is my club on tonight"
Some checks failed
Build TV app / build (push) Failing after 2s
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 <noreply@anthropic.com>
2026-08-26 02:47:06 +02:00
be-nj
753ab3a3ab Follow the repo move to the be-nj organisation
Some checks failed
Build TV app / build (push) Failing after 2s
Updater and README point at the new path. The old one redirects, but the
updater should not depend on that. Also drops a stale GitHub-specific
Accept header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:33:47 +02:00
12 changed files with 405 additions and 18 deletions

View File

@@ -38,7 +38,7 @@ TV-Fernbedienung oder steuert alles bequem vom Handy aus.
1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen 1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen
Weg nutzen, eine APK-Datei zu öffnen). Weg nutzen, eine APK-Datei zu öffnen).
2. Diese Adresse eingeben: 2. Diese Adresse eingeben:
`git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk` `git.beckm4nn.net/be-nj/castarr/raw/branch/apk/castarr.apk`
3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben, 3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben,
wenn der Fernseher fragt). wenn der Fernseher fragt).
4. Castarr starten und den drei Schritten auf dem Bildschirm folgen: 4. Castarr starten und den drei Schritten auf dem Bildschirm folgen:

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 24 versionCode = 26
versionName = "0.9.1" versionName = "0.10.0"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue
import dev.castarr.tv.auth.DeviceAuth import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.data.DispatcharrRepository import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.data.NowNext import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.data.SourceRepository import dev.castarr.tv.data.SourceRepository
import dev.castarr.tv.player.PlayerController import dev.castarr.tv.player.PlayerController
import dev.castarr.tv.playlist.Channel import dev.castarr.tv.playlist.Channel
@@ -38,6 +39,30 @@ class AppState(
var isLive by mutableStateOf(false) var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false) var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null) var groupFilter by mutableStateOf<String?>(null)
/** Club shortcut currently open in the rail, if any. */
var activeTeam by mutableStateOf<String?>(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<String> {
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<List<PlayerController.AudioTrack>>(emptyList()) var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
private set private set
private var lastNowTitle: String? = null private var lastNowTitle: String? = null
@@ -132,6 +157,29 @@ class AppState(
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel) 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<Pair<Channel, dev.castarr.tv.data.Programme>> {
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() { fun refreshActive() {
when (sourceMode) { when (sourceMode) {
SourceMode.GENERIC -> source.refresh() SourceMode.GENERIC -> source.refresh()
@@ -168,6 +216,8 @@ class AppState(
} }
fun startOnboarding() { fun startOnboarding() {
prefs.edit().remove("teams").apply()
activeTeam = null
auth.logout() auth.logout()
appError = AppError.NONE appError = AppError.NONE
welcomePhase = WelcomePhase.WAIT_PHONE welcomePhase = WelcomePhase.WAIT_PHONE

View File

@@ -71,6 +71,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
fun nowNext(channel: Channel): NowNext = fun nowNext(channel: Channel): NowNext =
XmltvParser.nowNext(programmesByTvgId[channel.tvgId]) XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
/** Programmes of this channel between now and now + [windowMs]. */
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
val now = System.currentTimeMillis()
return XmltvParser.programmesIn(programmesByTvgId[channel.tvgId], now, now + windowMs)
}
fun toggleFavorite(channel: Channel) { fun toggleFavorite(channel: Channel) {
if (channel.backendId == 0) return if (channel.backendId == 0) return
scope.launch { scope.launch {

View File

@@ -82,13 +82,20 @@ class SourceRepository(context: Context) {
status.value = "" status.value = ""
} }
/** Now/Next for an M3U channel: match tvg-id first, then name. */ /** Programmes of this channel between now and now + [windowMs]. */
fun nowNext(channel: Channel): NowNext { fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] } val now = System.currentTimeMillis()
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] } return XmltvParser.programmesIn(programmesFor(channel), now, now + windowMs)
return XmltvParser.nowNext(byName)
} }
private fun programmesFor(channel: Channel): List<Programme>? {
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() { private fun loadCachedChannels() {
runCatching { runCatching {
val cached = prefs.getString("channels_cache", null) ?: return val cached = prefs.getString("channels_cache", null) ?: return

View File

@@ -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<String>,
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()]
}

View File

@@ -105,6 +105,14 @@ object XmltvParser {
} }
} }
/** Programmes overlapping [from]..[to], in broadcast order. */
fun programmesIn(
programmes: List<Programme>?,
from: Long,
to: Long,
): List<Programme> =
programmes.orEmpty().filter { it.stop > from && it.start < to }
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext { fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
if (programmes.isNullOrEmpty()) return NowNext(null, null) if (programmes.isNullOrEmpty()) return NowNext(null, null)
val index = programmes.indexOfFirst { at < it.stop } val index = programmes.indexOfFirst { at < it.stop }

View File

@@ -1,6 +1,7 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -29,6 +30,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@@ -71,7 +75,19 @@ fun LiveScreen(state: AppState) {
val groups = remember(allChannels) { val groups = remember(allChannels) {
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted() 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 { val channels = when {
activeTeam != null -> activeMatches.map { it.first }
state.favoritesOnly -> allChannels.filter { it.backendId in favorites } state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter } state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels else -> allChannels
@@ -128,7 +144,8 @@ fun LiveScreen(state: AppState) {
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
item { item {
val selected = !state.favoritesOnly && state.groupFilter == null val selected = !state.favoritesOnly && state.groupFilter == null &&
state.activeTeam == null
GroupItem( GroupItem(
label = "Alle Sender", label = "Alle Sender",
count = allChannels.size, count = allChannels.size,
@@ -138,6 +155,7 @@ fun LiveScreen(state: AppState) {
), ),
suppressAutoSelect = { state.restorePending }, suppressAutoSelect = { state.restorePending },
) { ) {
state.activeTeam = null
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = null state.groupFilter = null
} }
@@ -147,17 +165,35 @@ fun LiveScreen(state: AppState) {
GroupItem( GroupItem(
label = "★ Favoriten", label = "★ Favoriten",
count = favorites.size, count = favorites.size,
selected = state.favoritesOnly, 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
), ),
suppressAutoSelect = { state.restorePending }, suppressAutoSelect = { state.restorePending },
) { ) {
state.activeTeam = null
state.favoritesOnly = true state.favoritesOnly = true
state.groupFilter = null 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 { item {
Box( Box(
Modifier Modifier
@@ -171,12 +207,13 @@ fun LiveScreen(state: AppState) {
GroupItem( GroupItem(
label = group, label = group,
count = remember(allChannels, group) { allChannels.count { it.group == group } }, count = remember(allChannels, group) { allChannels.count { it.group == group } },
selected = state.groupFilter == group, selected = state.groupFilter == group && state.activeTeam == null,
modifier = intoList.then( modifier = intoList.then(
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
), ),
suppressAutoSelect = { state.restorePending }, suppressAutoSelect = { state.restorePending },
) { ) {
state.activeTeam = null
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = group state.groupFilter = group
} }
@@ -196,9 +233,13 @@ fun LiveScreen(state: AppState) {
if (channels.isEmpty()) { if (channels.isEmpty()) {
item { item {
Text( Text(
if (state.favoritesOnly) when {
"Noch keine Favoriten — halte OK auf einem Sender gedrückt." activeTeam != null ->
else "Diese Gruppe ist leer.", "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, color = CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 14.sp, fontSize = 14.sp,
@@ -220,6 +261,7 @@ fun LiveScreen(state: AppState) {
else Modifier else Modifier
), ),
nowNext = state.nowNext(channel), nowNext = state.nowNext(channel),
highlight = teamHits[channel.url],
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.
@@ -241,6 +283,7 @@ private fun GroupItem(
count: Int, count: Int,
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
suppressAutoSelect: () -> Boolean = { false }, suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit, onSelect: () -> Unit,
) { ) {
@@ -264,6 +307,10 @@ private fun GroupItem(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (leading != null) {
leading()
Spacer(Modifier.width(10.dp))
}
Text( Text(
label, label,
fontFamily = AppFont, fontFamily = AppFont,
@@ -289,6 +336,7 @@ private fun ChannelRow(
number: Int, number: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
nowNext: NowNext, nowNext: NowNext,
highlight: dev.castarr.tv.data.Programme? = null,
playing: Boolean, playing: Boolean,
favorite: Boolean, favorite: Boolean,
epgStamp: Long, epgStamp: Long,
@@ -349,7 +397,13 @@ private fun ChannelRow(
} }
} }
Spacer(Modifier.width(24.dp)) 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) { if (playing) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Box( 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. */ /** Bare channel logo (they ship transparent); initials as fallback. */
@Composable @Composable
private fun LogoTile(channel: Channel) { 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 = private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))

View File

@@ -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") { SettingsCard("App") {
SettingRow( SettingRow(
"Version ${BuildConfig.VERSION_NAME}", "Version ${BuildConfig.VERSION_NAME}",

View File

@@ -18,12 +18,12 @@ object UpdateChecker {
private const val TAG = "UpdateChecker" private const val TAG = "UpdateChecker"
private const val LATEST = private const val LATEST =
"https://git.beckm4nn.net/api/v1/repos/benjamin/castarr/releases/latest" "https://git.beckm4nn.net/api/v1/repos/be-nj/castarr/releases/latest"
// Release assets need an API token to upload, so the APK is served from // Release assets need an API token to upload, so the APK is served from
// an orphan branch instead — anonymously fetchable on a public repo. // an orphan branch instead — anonymously fetchable on a public repo.
private const val APK_FALLBACK = private const val APK_FALLBACK =
"https://git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk" "https://git.beckm4nn.net/be-nj/castarr/raw/branch/apk/castarr.apk"
private var apkUrl: String = "" private var apkUrl: String = ""
@@ -103,7 +103,7 @@ object UpdateChecker {
return try { return try {
connection.connectTimeout = 10_000 connection.connectTimeout = 10_000
connection.readTimeout = 15_000 connection.readTimeout = 15_000
connection.setRequestProperty("Accept", "application/vnd.github+json") connection.setRequestProperty("Accept", "application/json")
connection.inputStream.bufferedReader().use { it.readText() } connection.inputStream.bufferedReader().use { it.readText() }
} finally { } finally {
connection.disconnect() connection.disconnect()

View File

@@ -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"))
}
}

View File

@@ -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<Programme>(), XmltvParser.programmesIn(null, 0, 1_000))
}
}