3 Commits

Author SHA1 Message Date
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
10 changed files with 160 additions and 28 deletions

View File

@@ -43,9 +43,6 @@ jobs:
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
- name: Fetch club crests
run: python3 tools/fetch-crests.py || true
- name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon --stacktrace

View File

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

View File

@@ -170,22 +170,31 @@ class AppState(
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
* 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>> {
fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
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()
.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 }
// A channel can carry several of the club's broadcasts in one
// evening; the row names the next and counts the rest.
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() {
@@ -200,8 +209,14 @@ class AppState(
result.fold(
onSuccess = { appError = AppError.NONE },
onFailure = { throwable ->
// Cached channels keep the app usable; only surface a
// fullscreen state when there is nothing to show.
// An expired login must always surface: cached channels
// 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
appError = when {
throwable.message == "not logged in" -> AppError.RELOGIN

View File

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

View File

@@ -29,8 +29,25 @@ data class TeamFilter(
object TeamFilters {
/** Window scanned ahead of now. */
const val WINDOW_MS = 3 * 60 * 60 * 1000L
/** Minimum window scanned ahead of now. */
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. */
const val MAX_ACTIVE = 3

View File

@@ -246,7 +246,7 @@ fun LiveScreen(state: AppState) {
Text(
when {
activeTeam != null ->
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
state.favoritesOnly ->
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else -> "Diese Gruppe ist leer."
@@ -351,7 +351,7 @@ private fun ChannelRow(
number: Int,
modifier: Modifier = Modifier,
nowNext: NowNext,
highlight: dev.castarr.tv.data.Programme? = null,
highlight: AppState.TeamHit? = null,
playing: Boolean,
favorite: Boolean,
epgStamp: Long,
@@ -510,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
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
Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) {
@@ -536,7 +537,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo
}
Spacer(Modifier.height(6.dp))
Text(
"${formatClock(programme.start)}${formatClock(programme.stop)}",
"${formatClock(programme.start)}${formatClock(programme.stop)}" +
if (hit.further > 0) " · +${hit.further} weitere" else "",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,

View File

@@ -178,8 +178,8 @@ fun SettingsScreen(state: AppState) {
SettingsCard("Vereinsmenüs") {
Text(
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
"nächsten 3 Stunden läuft. Bis zu " +
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
"heute noch läuft. Bis zu " +
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
@@ -251,6 +251,10 @@ fun SettingsScreen(state: AppState) {
}
}
}
SettingRow(
"Senderliste neu laden",
subtitle = "Holt Sender, Programm und Favoriten erneut",
) { state.refreshActive() }
SettingRow(
"Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen",

View File

@@ -51,6 +51,25 @@ class TeamFiltersTest {
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
fun `covers all three divisions`() {
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"
}

View File

@@ -14,11 +14,16 @@ 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 = []
@@ -35,11 +40,15 @@ for key, full_name in clubs:
skipped += 1
continue
try:
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")
thumb = sources.get(key)
if not thumb:
raise ValueError("no thumbnail")
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:
@@ -51,4 +60,8 @@ for key, full_name in clubs:
# 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")