8 Commits

Author SHA1 Message Date
be-nj
b0b8f8801d Stop throwing out honest remotes, and mirror club menus to the phone
All checks were successful
Build TV app / build (push) Successful in 4m39s
The rate limit charged every hello, not just failed ones. The phone remote
reconnects on any network hiccup, so five reconnects in a minute — entirely
normal — got it kicked with "zu viele Versuche". Only failed authentication
costs budget now, and a success clears the address.

The club menus existed only on the TV. The phone now receives them and
shows a chip per club next to Alle/Favoriten, listing the matching channels
with kick-off time instead of the usual now/next.

Settings: the club row showed an "An" pill although it can only ever
remove, which read like a toggle. It is a remove icon now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:25:22 +02:00
be-nj
9a0fa37e55 Club menus look ahead to midnight and count repeat broadcasts
All checks were successful
Build TV app / build (push) Successful in 3m9s
A three-hour window was empty most of the day, which is exactly when
someone wants to know whether their club plays tonight. The scan now runs
until midnight, never less than three hours, so a late-evening question
still covers the rest of the night.

A channel can carry several of the club's broadcasts in one evening — a
preview at 18:20 and the match at 21:45 on the same station. The row names
the next one and appends "+n weitere" instead of silently hiding the rest,
so the list stays a channel list where one click means one station.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:01:14 +02:00
be-nj
73f6dbba75 Surface failed refreshes instead of swallowing them
All checks were successful
Build TV app / build (push) Successful in 2m54s
Favourites and stream profiles were refreshed inside a bare runCatching,
so when they failed nothing was logged and the app just showed a stale
cached list. An expired login now always raises the re-login screen, even
when cached channels could still be displayed, and the settings gained a
"Senderliste neu laden" row for a manual retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:59:25 +02:00
be-nj
f6f6a2499f Stop the CI from re-fetching crests, cache their URLs instead
All checks were successful
Build TV app / build (push) Successful in 2m49s
Fetching 56 images on every run earned nothing but rate limits, and the
CI artefact is a test build that does not need the badges — the shield
fallback covers it. The resolved image URLs are cached in
tools/crest-sources.json, which is plain text and safe to commit, so a
local run needs one request per club instead of two and can resume after
a rate limit. The artwork itself stays out of the repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:42:49 +02:00
be-nj
8aa3a4da02 Ship the real club crests in the APK, and fix the CI licence step
All checks were successful
Build TV app / build (push) Successful in 4m15s
Resolving crests from Wikipedia at runtime never produced a visible badge
on the TV and left nothing in the log to debug, so the shield fallback was
all anyone ever saw. They are now downloaded once at build time by
tools/fetch-crests.py into assets/crests (git-ignored, so no trademarked
artwork is committed) and loaded from there — instant, and independent of
the network.

CI: accepting SDK licences by piping "yes" into sdkmanager died of SIGPIPE
(exit 141) under pipefail; the licence hashes are written directly now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:31:06 +02:00
be-nj
b7ee0523d1 CI: build without marketplace actions
Some checks failed
Build TV app / build (push) Failing after 21s
The runner resolves bare action names against this Gitea, and even with
full GitHub URLs it fails on annotated tags (unsupported object type).
The image also ships neither a JDK nor an Android SDK, so the job never
had a chance. Everything is plain shell now: shallow checkout, JDK 17,
command-line SDK tools, tests, debug APK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:10:43 +02:00
be-nj
a3105925bb Make a waiting update visible instead of hiding it in the settings
Some checks failed
Build TV app / build (push) Failing after 5s
The start-up check already ran, but its result only showed as a badge for
whoever happened to open the settings. A chip in the top bar now says
"Update <version>" and leads straight there, and the check repeats every
six hours because a TV keeps the same process alive for days.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 03:07:12 +02:00
be-nj
7f1dff5661 Real club crests, and no more crash on "Verein hinzufügen"
Some checks failed
Build TV app / build (push) Failing after 5s
Crests are resolved from the German Wikipedia page summary at runtime and
cached per device, so nothing copyrighted is stored in the repository. The
coloured shield remains as the fallback while the badge loads or when a
club has none.

The add dialog crashed for the same reason the rail did: it focused a row
that only exists when something is preselected, and adding a club has no
preselection. It now also scrolls, which 56 clubs rather need.

CI: this Gitea resolves bare action names against itself, so the workflow
now uses full GitHub URLs, and it runs the unit tests too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:59:23 +02:00
16 changed files with 518 additions and 66 deletions

View File

@@ -4,24 +4,53 @@ on:
push: push:
workflow_dispatch: workflow_dispatch:
# No marketplace actions: this Gitea runner resolves bare action names
# against the local instance, and even with full URLs it cannot resolve
# annotated tags ("unsupported object type"). Everything below is plain
# shell, which also keeps the job readable.
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
ANDROID_SDK_ROOT: /opt/android-sdk
CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
run: |
git init -q .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD
- uses: actions/setup-java@v4 - name: Install JDK
with: run: |
distribution: temurin apt-get update -qq
java-version: "17" DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-17-jdk-headless > /dev/null
java -version
- uses: gradle/actions/setup-gradle@v4 - name: Install Android SDK
run: |
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
unzip -q /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
# Accept licences by writing the hashes: piping "yes" into
# sdkmanager dies of SIGPIPE (exit 141) under pipefail.
mkdir -p "$ANDROID_SDK_ROOT/licenses"
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license"
echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license"
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
- name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace
- name: Build debug APK - name: Build debug APK
run: ./gradlew assembleDebug --stacktrace run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Upload APK - name: Summary
uses: actions/upload-artifact@v4 if: always()
with: run: |
name: castarr-debug-apk echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
path: app/build/outputs/apk/debug/app-debug.apk echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ local.properties
*.iml *.iml
.kotlin/ .kotlin/
tests/runs/ tests/runs/
app/src/main/assets/crests/

View File

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

View File

@@ -267,7 +267,7 @@
ws: null, connected: false, authorized: false, ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 }, status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false, channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
retryDelay: 1000, volumeDragging: false, searchTerm: '', retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
}; };
// --- pairing credentials --- // --- pairing credentials ---
@@ -450,16 +450,31 @@
list.innerHTML = ''; list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []); const favSet = new Set(state.extras.favorites || []);
const favOn = state.extras.favoritesSupported && state.favOnly; const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered; const teams = state.extras.teams || [];
if (state.extras.favoritesSupported) { const club = teams.find((t) => t.key === state.teamKey);
// Club view: the TV's club menu, mirrored onto the phone.
const clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
const shown = club
? filtered.filter((c) => clubHits.has(c.url))
: favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported || teams.length) {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px'; bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => { const views = [['Alle', null, false]];
if (state.extras.favoritesSupported) views.push(['★ Favoriten', null, true]);
teams.forEach((t) => views.push([t.label + ' ' + t.entries.length, t.key, false]));
views.forEach(([label, key, fav]) => {
const active = key ? state.teamKey === key : (!state.teamKey && state.favOnly === fav);
const chip = document.createElement('button'); const chip = document.createElement('button');
chip.textContent = label; chip.textContent = label;
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' + chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8'); (active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); }); chip.addEventListener('click', () => {
state.teamKey = key;
state.favOnly = fav;
listSignature = '';
renderChannels();
});
bar.appendChild(chip); bar.appendChild(chip);
}); });
list.appendChild(bar); list.appendChild(bar);
@@ -467,8 +482,9 @@
if (!shown.length) { if (!shown.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'empty'; empty.className = 'empty';
empty.textContent = favOn empty.textContent = club
? 'Keine Favoriten — Stern auf einem Sender antippen.' ? ('Heute läuft nichts mehr mit ' + club.name + '.')
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
: 'Keine Sender gefunden.'; : 'Keine Sender gefunden.';
list.appendChild(empty); list.appendChild(empty);
return; return;
@@ -493,6 +509,18 @@
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name; name.className = 'name'; name.textContent = c.name;
meta.appendChild(name); meta.appendChild(name);
const hit = clubHits ? clubHits.get(c.url) : null;
if (hit) {
const line = document.createElement('span');
line.className = 'grp';
const t = new Date(hit.start);
const hh = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0');
const running = Date.now() >= hit.start && Date.now() < hit.stop;
line.textContent = (running ? 'läuft · ' : 'ab ' + hh + ' · ') + hit.title +
(hit.further > 0 ? ' · +' + hit.further + ' weitere' : '');
line.style.color = running ? '#5fd4c4' : '#9aa0a8';
meta.appendChild(line);
} else {
const info = (state.extras.nowNext || [])[idx]; const info = (state.extras.nowNext || [])[idx];
if (info && info.now) { if (info && info.now) {
const now = document.createElement('span'); const now = document.createElement('span');
@@ -513,6 +541,7 @@
grp.className = 'grp'; grp.textContent = c.group; grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp); meta.appendChild(grp);
} }
}
btn.appendChild(num); btn.appendChild(meta); btn.appendChild(num); btn.appendChild(meta);
if (state.extras.favoritesSupported && c.backendId) { if (state.extras.favoritesSupported && c.backendId) {
const star = document.createElement('span'); const star = document.createElement('span');
@@ -539,7 +568,8 @@
let listSignature = ''; let listSignature = '';
function renderChannelsIfChanged() { function renderChannelsIfChanged() {
const sig = [ const sig = [
state.channels.length, state.searchTerm, state.favOnly, state.channels.length, state.searchTerm, state.favOnly, state.teamKey,
JSON.stringify((state.extras.teams || []).map((t) => [t.key, t.entries.length])),
(state.extras.favorites || []).join(','), (state.extras.favorites || []).join(','),
state.status.channel || '', state.status.channel || '',
].join('|'); ].join('|');

View File

@@ -27,6 +27,7 @@ class AppState(
val dispatcharr: DispatcharrRepository, val dispatcharr: DispatcharrRepository,
) { ) {
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
val crests = dev.castarr.tv.data.Crests(context)
var screen by mutableStateOf(Screen.LIVE) var screen by mutableStateOf(Screen.LIVE)
var playerVisible by mutableStateOf(false) var playerVisible by mutableStateOf(false)
@@ -169,22 +170,31 @@ class AppState(
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs) SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
} }
/** One channel's club broadcasts: the next one plus how many follow. */
data class TeamHit(
val programme: dev.castarr.tv.data.Programme,
val further: Int,
)
/** /**
* Channels showing the viewer's club within the scanned window, paired * Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever * with the programme that matched — earliest kick-off first, so whatever
* is running right now sits on top. * is running right now sits on top.
*/ */
fun teamMatches(key: String): List<Pair<Channel, dev.castarr.tv.data.Programme>> { fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList() val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS val now = System.currentTimeMillis()
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
return activeChannels() return activeChannels()
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) } .filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
.mapNotNull { channel -> .mapNotNull { channel ->
upcoming(channel, window) // A channel can carry several of the club's broadcasts in one
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) } // evening; the row names the next and counts the rest.
?.let { channel to it } val hits = upcoming(channel, window)
.filter { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
hits.firstOrNull()?.let { channel to TeamHit(it, hits.size - 1) }
} }
.sortedBy { it.second.start } .sortedBy { it.second.programme.start }
} }
fun refreshActive() { fun refreshActive() {
@@ -199,8 +209,14 @@ class AppState(
result.fold( result.fold(
onSuccess = { appError = AppError.NONE }, onSuccess = { appError = AppError.NONE },
onFailure = { throwable -> onFailure = { throwable ->
// Cached channels keep the app usable; only surface a // An expired login must always surface: cached channels
// fullscreen state when there is nothing to show. // would otherwise sit there without EPG or favourites and
// nothing would say why.
if (throwable.message == "not logged in") {
appError = AppError.RELOGIN
return
}
// Otherwise cached channels keep the app usable.
if (activeChannels().isNotEmpty()) return if (activeChannels().isNotEmpty()) return
appError = when { appError = when {
throwable.message == "not logged in" -> AppError.RELOGIN throwable.message == "not logged in" -> AppError.RELOGIN

View File

@@ -50,7 +50,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
server.startServer() server.startServer()
state.remoteAvailable = server.running state.remoteAvailable = server.running
setContent { CastarrApp(state) } setContent { CastarrApp(state) }
lifecycleScope.launch { UpdateChecker.check(state) } lifecycleScope.launch {
// At start-up and then occasionally: a TV often keeps the same
// app process alive for days.
while (true) {
UpdateChecker.check(state)
delay(UPDATE_CHECK_INTERVAL_MS)
}
}
} }
private fun onPlaybackChanged() { private fun onPlaybackChanged() {
@@ -140,9 +147,36 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
} }
val favorites = org.json.JSONArray() val favorites = org.json.JSONArray()
state.dispatcharr.favorites.value.forEach { favorites.put(it) } state.dispatcharr.favorites.value.forEach { favorites.put(it) }
// The club menus exist on the TV; the phone should offer them too.
val teams = org.json.JSONArray()
dev.castarr.tv.data.TeamFilters.all
.filter { it.key in state.enabledTeams }
.forEach { club ->
val entries = org.json.JSONArray()
state.teamMatches(club.key).forEach { (channel, hit) ->
entries.put(
JSONObject()
.put("channel", channel.name)
.put("url", channel.url)
.put("group", channel.group)
.put("title", hit.programme.title)
.put("start", hit.programme.start)
.put("stop", hit.programme.stop)
.put("further", hit.further)
)
}
teams.put(
JSONObject()
.put("key", club.key)
.put("label", club.label)
.put("name", club.fullName)
.put("entries", entries)
)
}
return JSONObject() return JSONObject()
.put("nowNext", nowNext) .put("nowNext", nowNext)
.put("favorites", favorites) .put("favorites", favorites)
.put("teams", teams)
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR) .put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
} }
@@ -325,5 +359,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
const val TICK_INTERVAL_MS = 2_000L const val TICK_INTERVAL_MS = 2_000L
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
} }
} }

View File

@@ -0,0 +1,63 @@
package dev.castarr.tv.data
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Resolves club crests at runtime from Wikipedia's page summary. The badges
* are trademarks, so they are fetched and cached on the device rather than
* shipped with the app — nothing copyrighted lives in the repository.
*/
class Crests(context: Context) {
private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE)
/** Cached crest URL for a club, fetched once and then reused. */
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
prefs.getString(article, null)?.takeIf { it.isNotEmpty() }?.let {
return@withContext it
}
val resolved = runCatching { fetch(article) }
.onFailure { Log.w(TAG, "crest lookup failed for $article: $it") }
.getOrNull()
if (resolved != null) {
Log.i(TAG, "crest for $article: $resolved")
prefs.edit().putString(article, resolved).apply()
} else {
// Deliberately not cached: a single failed lookup (no network
// yet at start-up) must not hide the crest forever.
Log.w(TAG, "no crest for $article")
}
resolved
}
private fun fetch(article: String): String? {
val encoded = URLEncoder.encode(article, "UTF-8").replace("+", "%20")
val connection = URL("$SUMMARY$encoded").openConnection() as HttpURLConnection
return try {
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.setRequestProperty("User-Agent", USER_AGENT)
connection.setRequestProperty("Accept", "application/json")
val body = connection.inputStream.bufferedReader().use { it.readText() }
JSONObject(body).optJSONObject("thumbnail")?.optString("source")
?.takeIf { it.isNotEmpty() }
// Drop the analytics query the API appends.
?.substringBefore("?")
} finally {
connection.disconnect()
}
}
private companion object {
const val TAG = "Crests"
const val SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
const val USER_AGENT = "Castarr TV (private use)"
}
}

View File

@@ -49,13 +49,20 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
scope.launch { scope.launch {
val result = runCatching { val result = runCatching {
status.value = "loading_channels" status.value = "loading_channels"
Log.i(TAG, "refresh start (loggedIn=${auth.isLoggedIn})")
val token = auth.accessToken() ?: error("not logged in") val token = auth.accessToken() ?: error("not logged in")
val groups = fetchGroups(token) val groups = fetchGroups(token)
val list = fetchChannels(token, groups) val list = fetchChannels(token, groups)
channels.value = list channels.value = list
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply() prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
launch { runCatching { refreshFavorites(token) } } launch {
launch { runCatching { refreshProfiles(token) } } runCatching { refreshFavorites(token) }
.onFailure { Log.w(TAG, "favorites failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
}
launch {
runCatching { refreshProfiles(token) }
.onFailure { Log.w(TAG, "profiles failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
}
launch { launch {
runCatching { refreshEpg(token) } runCatching { refreshEpg(token) }
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") } .onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }

View File

@@ -5,8 +5,8 @@ import androidx.compose.ui.graphics.Color
/** /**
* Per-club shortcut to "where does my club play tonight": a rail entry that * 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. * 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 * Crests are fetched from Wikipedia at runtime and cached on the device;
* colours rather than the real badge. * the club colours are the fallback while that is pending or unavailable.
*/ */
data class TeamFilter( data class TeamFilter(
val key: String, val key: String,
@@ -18,6 +18,8 @@ data class TeamFilter(
val needles: List<String>, val needles: List<String>,
val primary: Color, val primary: Color,
val secondary: Color, val secondary: Color,
/** German Wikipedia article the crest is resolved from. */
val article: String,
) { ) {
fun matches(title: String): Boolean { fun matches(title: String): Boolean {
val haystack = title.lowercase() val haystack = title.lowercase()
@@ -27,8 +29,25 @@ data class TeamFilter(
object TeamFilters { object TeamFilters {
/** Window scanned ahead of now. */ /** Minimum window scanned ahead of now. */
const val WINDOW_MS = 3 * 60 * 60 * 1000L const val MIN_WINDOW_MS = 3 * 60 * 60 * 1000L
/**
* Scans until midnight, but never less than [MIN_WINDOW_MS] — asking in
* the morning should already answer "does my club play tonight", and
* asking late at night must not cut the evening short.
*/
fun windowEnd(now: Long): Long {
val midnight = java.util.Calendar.getInstance().apply {
timeInMillis = now
add(java.util.Calendar.DAY_OF_YEAR, 1)
set(java.util.Calendar.HOUR_OF_DAY, 0)
set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 0)
set(java.util.Calendar.MILLISECOND, 0)
}.timeInMillis
return maxOf(midnight, now + MIN_WINDOW_MS)
}
/** At most this many club groups sit in the rail at once. */ /** At most this many club groups sit in the rail at once. */
const val MAX_ACTIVE = 3 const val MAX_ACTIVE = 3
@@ -43,7 +62,8 @@ object TeamFilters {
needles: List<String>, needles: List<String>,
primary: Long, primary: Long,
secondary: Long, secondary: Long,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary)) article: String = fullName,
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article)
/** Clubs of the top three German divisions. */ /** Clubs of the top three German divisions. */
val all: List<TeamFilter> = listOf( val all: List<TeamFilter> = listOf(
@@ -94,7 +114,7 @@ object TeamFilters {
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE), club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE),
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE), club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE),
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK), club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK),
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE), club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball"),
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE), club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE),
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE), club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE), club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
@@ -105,7 +125,7 @@ object TeamFilters {
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK), club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2), club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK), club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK),
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK), club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim"),
) )
/** Clubs switched on for a viewer before they touch the settings. */ /** Clubs switched on for a viewer before they touch the settings. */

View File

@@ -64,19 +64,34 @@ class ControlServer(
// from spending someone else's budget. // from spending someone else's budget.
private val attempts = HashMap<String, ArrayDeque<Long>>() private val attempts = HashMap<String, ArrayDeque<Long>>()
/** True while this address may still try; does not consume budget. */
@Synchronized @Synchronized
private fun attemptAllowed(address: String): Boolean { private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val queue = attempts.getOrPut(address) { ArrayDeque() } val queue = attempts[address] ?: return true
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) { while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst() queue.removeFirst()
} }
return queue.size < ATTEMPT_MAX
}
/**
* Only *failed* authentication costs budget. Counting successes too
* threw out honest clients: the phone remote reconnects on every
* network hiccup, and five reconnects a minute are normal.
*/
@Synchronized
private fun recordFailure(address: String) {
val queue = attempts.getOrPut(address) { ArrayDeque() }
queue.addLast(System.currentTimeMillis())
if (attempts.size > MAX_TRACKED_ADDRESSES) { if (attempts.size > MAX_TRACKED_ADDRESSES) {
attempts.entries.removeAll { it.value.isEmpty() } attempts.entries.removeAll { it.value.isEmpty() }
} }
if (queue.size >= ATTEMPT_MAX) return false }
queue.addLast(now)
return true @Synchronized
private fun clearFailures(address: String) {
attempts.remove(address)
} }
fun startServer() { fun startServer() {
@@ -292,8 +307,6 @@ class ControlServer(
} }
private fun handleHello(msg: JSONObject) { private fun handleHello(msg: JSONObject) {
// Every failed attempt counts against this address, whether it
// carried a token or a code.
if (!attemptAllowed(remoteAddress)) { if (!attemptAllowed(remoteAddress)) {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
@@ -302,10 +315,12 @@ class ControlServer(
val tokenOk = Pairing.isValidToken(context, msg.optString("token")) val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code")) val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
if (!tokenOk && !codeOk) { if (!tokenOk && !codeOk) {
recordFailure(remoteAddress)
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return return
} }
clearFailures(remoteAddress)
authorized = true authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" } deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never // A code-authenticated client gets its own revocable token, never

View File

@@ -132,6 +132,10 @@ private fun TopBar(state: AppState) {
} }
Spacer(Modifier.width(18.dp)) Spacer(Modifier.width(18.dp))
} }
state.updateAvailable?.let { version ->
UpdateChip(version) { state.screen = AppState.Screen.SETTINGS }
Spacer(Modifier.width(10.dp))
}
GearButton { GearButton {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE else AppState.Screen.LIVE
@@ -139,6 +143,33 @@ private fun TopBar(state: AppState) {
} }
} }
/**
* Shown in the top bar as soon as the start-up check finds a newer
* release — otherwise an update would only be noticed by someone who
* happens to open the settings.
*/
@Composable
private fun UpdateChip(version: String, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.accentDim,
contentColor = CastarrColors.accent,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
"Update $version",
fontFamily = AppFont,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
/** Round settings button with a drawn gear (glyphs render as emoji). */ /** Round settings button with a drawn gear (glyphs render as emoji). */
@Composable @Composable
private fun GearButton(onClick: () -> Unit) { private fun GearButton(onClick: () -> Unit) {

View File

@@ -193,7 +193,7 @@ fun LiveScreen(state: AppState) {
label = club.label, label = club.label,
count = matchesByTeam[club.key]?.size ?: 0, count = matchesByTeam[club.key]?.size ?: 0,
selected = state.activeTeam == club.key, selected = state.activeTeam == club.key,
leading = { Crest(club) }, leading = { Crest(club, state) },
modifier = intoList.then( modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus) if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier else Modifier
@@ -246,7 +246,7 @@ fun LiveScreen(state: AppState) {
Text( Text(
when { when {
activeTeam != null -> activeTeam != null ->
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}." "Heute läuft nichts mehr mit ${activeTeam.fullName}."
state.favoritesOnly -> state.favoritesOnly ->
"Noch keine Favoriten — halte OK auf einem Sender gedrückt." "Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else -> "Diese Gruppe ist leer." else -> "Diese Gruppe ist leer."
@@ -351,7 +351,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, highlight: AppState.TeamHit? = null,
playing: Boolean, playing: Boolean,
favorite: Boolean, favorite: Boolean,
epgStamp: Long, epgStamp: Long,
@@ -433,11 +433,26 @@ private fun ChannelRow(
} }
/** /**
* Stand-in for the club badge: real crests are trademarks, so this draws a * The club badge. The images ship in the APK (fetched at build time by
* simple shield in the club colours instead. * tools/fetch-crests.py, never committed), so nothing has to load over the
* network; the coloured shield stands in if one is ever missing.
*/ */
@Composable @Composable
private fun Crest(team: dev.castarr.tv.data.TeamFilter) { private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
SubcomposeAsyncImage(
model = "file:///android_asset/crests/${team.key}.png",
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { ShieldFallback(team) },
error = { ShieldFallback(team) },
)
}
}
@Composable
private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) {
Canvas(Modifier.size(18.dp)) { Canvas(Modifier.size(18.dp)) {
val w = size.width val w = size.width
val h = size.height val h = size.height
@@ -495,9 +510,10 @@ private fun LogoInitials(initials: String) {
} }
} }
/** The programme that matched the club filter, with its start time. */ /** The next club broadcast on this channel, and how many follow it. */
@Composable @Composable
private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Modifier = Modifier) { private fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
val programme = hit.programme
val running = System.currentTimeMillis() in programme.start until programme.stop val running = System.currentTimeMillis() in programme.start until programme.stop
Column(modifier) { Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
@@ -521,7 +537,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo
} }
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
"${formatClock(programme.start)}${formatClock(programme.stop)}", "${formatClock(programme.start)}${formatClock(programme.stop)}" +
if (hit.further > 0) " · +${hit.further} weitere" else "",
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 12.sp,

View File

@@ -6,6 +6,8 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -14,9 +16,13 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -32,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -80,7 +87,14 @@ fun SettingsScreen(state: AppState) {
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 40.dp, vertical = 12.dp) .padding(horizontal = 40.dp, vertical = 12.dp)
) { ) {
Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) { // The cards outgrew one screen once club menus arrived; focus
// movement scrolls this column along.
Column(
Modifier
.weight(1.25f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingsCard("Konto") { SettingsCard("Konto") {
if (state.auth.isLoggedIn) { if (state.auth.isLoggedIn) {
Row( Row(
@@ -165,8 +179,8 @@ fun SettingsScreen(state: AppState) {
SettingsCard("Vereinsmenüs") { SettingsCard("Vereinsmenüs") {
Text( Text(
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " + "Eigene Gruppe mit allen Sendern, auf denen der Verein " +
"nächsten 3 Stunden läuft. Bis zu " + "heute noch läuft. Bis zu " +
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.", "${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp), modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
@@ -176,8 +190,8 @@ fun SettingsScreen(state: AppState) {
active.forEach { club -> active.forEach { club ->
SettingRow( SettingRow(
club.fullName, club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste", subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
trailing = { TogglePill(on = true) }, trailing = { RemoveIcon() },
) { state.toggleTeam(club.key) } ) { state.toggleTeam(club.key) }
} }
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) { if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
@@ -238,6 +252,10 @@ fun SettingsScreen(state: AppState) {
} }
} }
} }
SettingRow(
"Senderliste neu laden",
subtitle = "Holt Sender, Programm und Favoriten erneut",
) { state.refreshActive() }
SettingRow( SettingRow(
"Erweitert", "Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen", subtitle = "M3U/EPG-Adressen von Hand eintragen",
@@ -308,7 +326,13 @@ fun SettingsScreen(state: AppState) {
@Composable @Composable
private fun PickerDialog(picker: Picker, onClose: () -> Unit) { private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
val selectedFocus = remember { FocusRequester() } val selectedFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { selectedFocus.requestFocus() } // A requester bound to no composed row throws; with no preselection
// (adding something new) there is deliberately no such row.
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
val listState = rememberLazyListState()
LaunchedEffect(picker.selected) {
if (picker.selected > 0) listState.scrollToItem(picker.selected)
}
Dialog( Dialog(
onDismissRequest = onClose, onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
@@ -337,7 +361,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
letterSpacing = 2.sp, letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp), modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
) )
picker.options.forEachIndexed { index, option -> LazyColumn(
state = listState,
modifier = Modifier.heightIn(max = 420.dp),
) {
itemsIndexed(picker.options) { index, option ->
val selected = index == picker.selected val selected = index == picker.selected
Surface( Surface(
onClick = { onClick = {
@@ -346,7 +374,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier), .then(
if (selected || (picker.selected < 0 && index == 0))
Modifier.focusRequester(selectedFocus)
else Modifier
),
shape = ClickableSurfaceDefaults.shape(rowShape), shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
@@ -391,6 +423,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
} }
} }
} }
}
/** Current value plus a small drawn caret, shown at a row's trailing edge. */ /** Current value plus a small drawn caret, shown at a row's trailing edge. */
@Composable @Composable
@@ -420,6 +453,17 @@ private fun ValueWithCaret(value: String) {
} }
} }
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
@Composable
private fun RemoveIcon() {
val color = CastarrColors.muted
Canvas(Modifier.size(14.dp)) {
val stroke = 2.dp.toPx()
drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke)
drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke)
}
}
@Composable @Composable
private fun TogglePill(on: Boolean) { private fun TogglePill(on: Boolean) {
Box( Box(

View File

@@ -51,6 +51,25 @@ class TeamFiltersTest {
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } }) assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
} }
@Test
fun `window reaches midnight but never falls below three hours`() {
val cal = java.util.Calendar.getInstance()
// Morning: the window must reach into the evening.
cal.set(java.util.Calendar.HOUR_OF_DAY, 9)
cal.set(java.util.Calendar.MINUTE, 0)
val morning = cal.timeInMillis
val eveningKickoff = morning + 12 * 60 * 60 * 1000L
assertTrue(TeamFilters.windowEnd(morning) > eveningKickoff)
// Late at night: still at least three hours ahead.
cal.set(java.util.Calendar.HOUR_OF_DAY, 23)
cal.set(java.util.Calendar.MINUTE, 30)
val lateNight = cal.timeInMillis
assertTrue(
TeamFilters.windowEnd(lateNight) >= lateNight + TeamFilters.MIN_WINDOW_MS
)
}
@Test @Test
fun `covers all three divisions`() { fun `covers all three divisions`() {
assertTrue(TeamFilters.all.size > 50) assertTrue(TeamFilters.all.size > 50)

58
tools/crest-sources.json Normal file
View File

@@ -0,0 +1,58 @@
{
"aachen": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Alemannia_Aachen_2010.svg/langde-330px-Alemannia_Aachen_2010.svg.png",
"aue": "https://thumb.wikimedia.org/wikipedia/de/thumb/1/13/Fc_erzgebirge_aue.svg/langde-330px-Fc_erzgebirge_aue.svg.png",
"augsburg": "https://upload.wikimedia.org/wikipedia/de/thumb/b/b5/Logo_FC_Augsburg.svg/langde-330px-Logo_FC_Augsburg.svg.png",
"bayern": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg/langde-330px-FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg.png",
"bielefeld": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/fd/Arminia_Bielefeld_Logo_2021%E2%80%93.svg/langde-330px-Arminia_Bielefeld_Logo_2021%E2%80%93.svg.png",
"bochum": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/72/VfL_Bochum_logo.svg/langde-330px-VfL_Bochum_logo.svg.png",
"braunschweig": "https://thumb.wikimedia.org/wikipedia/de/thumb/4/45/Logo_Eintracht_Braunschweig.svg/langde-330px-Logo_Eintracht_Braunschweig.svg.png",
"bremen": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/SV-Werder-Bremen-Logo.svg/langde-330px-SV-Werder-Bremen-Logo.svg.png",
"bvb": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/Borussia_Dortmund_logo.svg/langde-330px-Borussia_Dortmund_logo.svg.png",
"cottbus": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Logo_Energie_Cottbus.svg/langde-330px-Logo_Energie_Cottbus.svg.png",
"darmstadt": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/SV_Darmstadt_98_Logo.svg/langde-330px-SV_Darmstadt_98_Logo.svg.png",
"dresden": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Logo_SG_Dynamo_Dresden_neu.svg/langde-330px-Logo_SG_Dynamo_Dresden_neu.svg.png",
"duesseldorf": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/94/Fortuna_D%C3%BCsseldorf.svg/langde-330px-Fortuna_D%C3%BCsseldorf.svg.png",
"duisburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Msv_duisburg_%282017%29.svg/langde-330px-Msv_duisburg_%282017%29.svg.png",
"elversberg": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/SV_Elversberg_Logo_2021.svg/langde-330px-SV_Elversberg_Logo_2021.svg.png",
"essen": "https://upload.wikimedia.org/wikipedia/de/thumb/8/8a/Logo_Rot-Weiss_Essen.svg/langde-330px-Logo_Rot-Weiss_Essen.svg.png",
"frankfurt": "https://upload.wikimedia.org/wikipedia/de/thumb/3/32/Logo_Eintracht_Frankfurt_1998.svg/langde-330px-Logo_Eintracht_Frankfurt_1998.svg.png",
"freiburg": "https://upload.wikimedia.org/wikipedia/de/thumb/b/bf/SC_Freiburg_Logo.svg/langde-330px-SC_Freiburg_Logo.svg.png",
"fuerth": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/SpVgg_Greuther_F%C3%BCrth_2017.svg/langde-330px-SpVgg_Greuther_F%C3%BCrth_2017.svg.png",
"gladbach": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/81/Borussia_M%C3%B6nchengladbach_logo.svg/langde-330px-Borussia_M%C3%B6nchengladbach_logo.svg.png",
"hannover": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Hannover_96_Logo.svg/langde-330px-Hannover_96_Logo.svg.png",
"hansa": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/8f/F.C._Hansa_Rostock_Logo.svg/langde-330px-F.C._Hansa_Rostock_Logo.svg.png",
"havelse": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/89/TSV_Havelse_logo.svg/langde-330px-TSV_Havelse_logo.svg.png",
"heidenheim": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9d/1._FC_Heidenheim_1846.svg/langde-330px-1._FC_Heidenheim_1846.svg.png",
"hertha": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/81/Hertha_BSC_Logo_2012.svg/langde-330px-Hertha_BSC_Logo_2012.svg.png",
"hoffenheim": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Logo_TSG_Hoffenheim.svg/langde-330px-Logo_TSG_Hoffenheim.svg.png",
"hoffenheim-ii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Logo_TSG_Hoffenheim.svg/langde-330px-Logo_TSG_Hoffenheim.svg.png",
"holstein": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Holstein_Kiel_Logo.svg/langde-330px-Holstein_Kiel_Logo.svg.png",
"hsv": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f7/Hamburger_SV_logo.svg/langde-330px-Hamburger_SV_logo.svg.png",
"ingolstadt": "https://thumb.wikimedia.org/wikipedia/de/thumb/5/55/FC-Ingolstadt_logo.svg/langde-330px-FC-Ingolstadt_logo.svg.png",
"kaiserslautern": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Logo_1_FC_Kaiserslautern.svg/langde-330px-Logo_1_FC_Kaiserslautern.svg.png",
"karlsruhe": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Karlsruher_SC_Logo_2.svg/langde-330px-Karlsruher_SC_Logo_2.svg.png",
"koeln": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/01/1._FC_Koeln_Logo_2014%E2%80%93.svg/langde-330px-1._FC_Koeln_Logo_2014%E2%80%93.svg.png",
"leipzig": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/2019-07-12_Fu%C3%9Fball%3B_Freundschaftsspiel_RB_Leipzig_-_FC_Z%C3%BCrich_1DX_0881_by_Stepro_2.png/330px-2019-07-12_Fu%C3%9Fball%3B_Freundschaftsspiel_RB_Leipzig_-_FC_Z%C3%BCrich_1DX_0881_by_Stepro_2.png",
"leverkusen": "https://upload.wikimedia.org/wikipedia/de/thumb/f/f7/Bayer_Leverkusen_Logo.svg/langde-330px-Bayer_Leverkusen_Logo.svg.png",
"magdeburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/1._FC_Magdeburg.svg/langde-330px-1._FC_Magdeburg.svg.png",
"mainz": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9e/Logo_Mainz_05.svg/langde-330px-Logo_Mainz_05.svg.png",
"mannheim": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Svwaldhof.svg/langde-330px-Svwaldhof.svg.png",
"muenster": "https://thumb.wikimedia.org/wikipedia/de/thumb/7/7e/SC_Preussen_Muenster_Logo_2018.svg/langde-330px-SC_Preussen_Muenster_Logo_2018.svg.png",
"nuernberg": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/1._FC_N%C3%BCrnberg_logo.svg/langde-330px-1._FC_N%C3%BCrnberg_logo.svg.png",
"osnabrueck": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/4e/VfL_Osnabrueck_Logo_2021%E2%80%93.svg/langde-330px-VfL_Osnabrueck_Logo_2021%E2%80%93.svg.png",
"paderborn": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/SC_Paderborn_07_Logo_new.svg/langde-330px-SC_Paderborn_07_Logo_new.svg.png",
"regensburg": "https://thumb.wikimedia.org/wikipedia/commons/thumb/3/3d/Jahn_Regensburg_logo2014.svg/langde-330px-Jahn_Regensburg_logo2014.svg.png",
"saarbruecken": "https://thumb.wikimedia.org/wikipedia/de/thumb/f/ff/1._FC_Saarbr%C3%BCcken.svg/langde-330px-1._FC_Saarbr%C3%BCcken.svg.png",
"schalke": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/FC_Schalke_04_Logo.svg/langde-330px-FC_Schalke_04_Logo.svg.png",
"schweinfurt": "https://thumb.wikimedia.org/wikipedia/de/thumb/7/7c/1._FC_Schweinfurt_05.svg/langde-330px-1._FC_Schweinfurt_05.svg.png",
"st-pauli": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Fc_st_pauli_logo.svg/langde-330px-Fc_st_pauli_logo.svg.png",
"stuttgart": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/eb/VfB_Stuttgart_1893_Logo.svg/langde-330px-VfB_Stuttgart_1893_Logo.svg.png",
"stuttgart-ii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/VfB_Stuttgart_1893_Logo.svg/langde-330px-VfB_Stuttgart_1893_Logo.svg.png",
"ulm": "https://thumb.wikimedia.org/wikipedia/commons/thumb/6/6c/SSV_Ulm_1846_Fussball.svg/langde-330px-SSV_Ulm_1846_Fussball.svg.png",
"union": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/1._FC_Union_Berlin_Logo.svg/langde-330px-1._FC_Union_Berlin_Logo.svg.png",
"verl": "https://thumb.wikimedia.org/wikipedia/commons/thumb/c/ce/SC_Verl_Logo.svg/langde-330px-SC_Verl_Logo.svg.png",
"viktoria": "https://thumb.wikimedia.org/wikipedia/commons/thumb/d/dc/FC_Viktoria_K%C3%B6ln_1904_Logo.svg/langde-330px-FC_Viktoria_K%C3%B6ln_1904_Logo.svg.png",
"wehen": "https://upload.wikimedia.org/wikipedia/de/e/e0/SV_Wehen_Logo.png",
"wolfsburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/VfL_Wolfsburg_logo_2026.svg/langde-330px-VfL_Wolfsburg_logo_2026.svg.png",
"wuppertal": "https://thumb.wikimedia.org/wikipedia/de/thumb/9/9b/WuppertalerSVLogo.svg/langde-330px-WuppertalerSVLogo.svg.png"
}

67
tools/fetch-crests.py Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Downloads club crests into the APK's assets at build time.
The badges are trademarks: they are fetched into a generated, git-ignored
folder so the repository stays free of them while the app ships with them
and needs no network at runtime.
"""
import json
import os
import re
import sys
import time
import urllib.parse
import urllib.request
OUT = sys.argv[1] if len(sys.argv) > 1 else "app/src/main/assets/crests"
URLS = "tools/crest-sources.json"
SRC = "app/src/main/java/dev/castarr/tv/data/TeamFilters.kt"
SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
UA = {"User-Agent": "Castarr build script (private use)"}
os.makedirs(OUT, exist_ok=True)
# Image URLs are plain text and safe to commit; the artwork itself is not.
sources = {}
if os.path.exists(URLS):
sources = json.load(open(URLS, encoding="utf-8"))
kotlin = open(SRC, encoding="utf-8").read()
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
clubs = []
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
clubs.append(raw)
# an explicit article = "..." wins over the club name
overrides = dict(re.findall(r'club\(\s*"([^"]+)"[^\n]*article = "([^"]+)"', kotlin))
clubs = [(k, overrides.get(k, n)) for k, n in clubs]
fetched = skipped = failed = 0
for key, full_name in clubs:
target = os.path.join(OUT, f"{key}.png")
if os.path.exists(target) and os.path.getsize(target) > 0:
skipped += 1
continue
try:
thumb = sources.get(key)
if not thumb:
url = SUMMARY + urllib.parse.quote(full_name)
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
thumb = json.load(r).get("thumbnail", {}).get("source")
if not thumb:
raise ValueError("no thumbnail")
sources[key] = thumb.split("?")[0]
thumb = sources[key]
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
data = r.read()
with open(target, "wb") as f:
f.write(data)
fetched += 1
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
print(f" {key}: {exc}", file=sys.stderr)
failed += 1
# Wikipedia rate-limits bursts; this runs rarely and caches.
time.sleep(1.2)
with open(URLS, "w", encoding="utf-8") as f:
json.dump(dict(sorted(sources.items())), f, indent=2, ensure_ascii=False)
f.write("\n")
print(f"crests: {fetched} geladen, {skipped} vorhanden, {failed} fehlgeschlagen")