Compare commits
4 Commits
v0.11.4
...
f1a6600e42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a6600e42 | ||
|
|
637679fd90 | ||
|
|
cbdac7ddb2 | ||
|
|
8bf1ed3128 |
@@ -73,9 +73,10 @@ Channel, no full guide timeline.
|
|||||||
## Example dialogue
|
## Example dialogue
|
||||||
|
|
||||||
> **Dev:** "Does the **Remote** need Backend credentials?"
|
> **Dev:** "Does the **Remote** need Backend credentials?"
|
||||||
> **Domain expert:** "No — the user enters the Xtream credentials of a
|
> **Domain expert:** "No — the Remote is where **Onboarding** happens, not
|
||||||
> **Quelle** once via the Remote, the **TV-App** stores them and is the only
|
> where credentials live: the user types the server URL there and finishes the
|
||||||
> one talking to the **Backend**."
|
> IdP login on the phone. The **TV-App** holds the tokens and is the only one
|
||||||
|
> talking to the **Backend**. A generic M3U **Quelle** needs no login at all."
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -79,4 +79,22 @@ sind signiert; über einem Debug-Build muss einmal deinstalliert werden
|
|||||||
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
||||||
Repos, die APK selbst liegt auf dem Branch `apk`.
|
Repos, die APK selbst liegt auf dem Branch `apk`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew test # Unit-Tests, Reports unter tests/runs/
|
||||||
|
tests/helpers/emulator.sh start # headloser Google-TV-Emulator
|
||||||
|
tests/helpers/emulator.sh install app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
tests/helpers/emulator.sh shot name # Screenshot nach tests/runs/screenshots/
|
||||||
|
tests/helpers/emulator.sh stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Emulator (AVD `castarr-googletv`, API 34) startet aus einem Snapshot und
|
||||||
|
ist in wenigen Sekunden oben; `stop` schreibt den Snapshot vorher neu. Ein
|
||||||
|
Kaltstart entsteht nur, wenn der Snapshot fehlt.
|
||||||
|
|
||||||
|
Demo-Daten für einen Lauf ohne echtes Backend: `tests/demo/make-demo-data.py`
|
||||||
|
erzeugt Playlist und EPG, die ein lokaler HTTP-Server als generische Quelle
|
||||||
|
ausliefert.
|
||||||
|
|
||||||
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
|
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 37
|
versionCode = 38
|
||||||
versionName = "0.11.4"
|
versionName = "0.11.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ object TeamFilters {
|
|||||||
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
|
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
|
||||||
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
|
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
|
||||||
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
|
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
|
||||||
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
|
club("heidenheim", "HDH", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
|
||||||
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
|
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
|
||||||
// --- 2. Bundesliga ---
|
// --- 2. Bundesliga ---
|
||||||
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
||||||
|
|||||||
@@ -357,22 +357,33 @@ private fun GroupItem(
|
|||||||
// The signal lives in the value column instead of adding a
|
// The signal lives in the value column instead of adding a
|
||||||
// badge — the count is worth less than "now" or "16:00" is.
|
// badge — the count is worth less than "now" or "16:00" is.
|
||||||
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
|
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
|
||||||
Text(
|
if (urgent == AppState.TeamUrgency.NONE) {
|
||||||
when (urgent) {
|
Text(
|
||||||
AppState.TeamUrgency.LIVE -> "läuft"
|
"$count",
|
||||||
AppState.TeamUrgency.SOON -> formatClock(signal!!.kickOff)
|
fontFamily = AppFont,
|
||||||
AppState.TeamUrgency.NONE -> "$count"
|
fontSize = 12.sp,
|
||||||
},
|
)
|
||||||
color = when (urgent) {
|
} else {
|
||||||
AppState.TeamUrgency.LIVE -> CastarrColors.live
|
// A focused row is filled with the accent colour, so signal
|
||||||
AppState.TeamUrgency.SOON -> CastarrColors.accent
|
// colour straight on the row was red on turquoise. The chip
|
||||||
AppState.TeamUrgency.NONE -> Color.Unspecified
|
// gives it a dark ground that holds on every row state.
|
||||||
},
|
Box(
|
||||||
fontFamily = AppFont,
|
Modifier
|
||||||
fontSize = 12.sp,
|
.clip(RoundedCornerShape(999.dp))
|
||||||
fontWeight = if (urgent == AppState.TeamUrgency.NONE) FontWeight.Normal
|
.background(CastarrColors.bgDeep.copy(alpha = 0.92f))
|
||||||
else FontWeight.SemiBold,
|
.padding(horizontal = 8.dp, vertical = 3.dp)
|
||||||
)
|
) {
|
||||||
|
Text(
|
||||||
|
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
|
||||||
|
else formatClock(signal!!.kickOff),
|
||||||
|
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||||
|
else CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,7 +486,9 @@ private fun Crest(
|
|||||||
state: AppState,
|
state: AppState,
|
||||||
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
// The box is wider than the crest on purpose: the pulse ring is drawn
|
||||||
|
// around it, and a box sized to the crest would clip the ring away.
|
||||||
|
Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) {
|
||||||
if (urgency != AppState.TeamUrgency.NONE) {
|
if (urgency != AppState.TeamUrgency.NONE) {
|
||||||
// A slow pulse is what actually catches the eye from the sofa;
|
// A slow pulse is what actually catches the eye from the sofa;
|
||||||
// colour alone does not at that distance.
|
// colour alone does not at that distance.
|
||||||
@@ -494,11 +507,11 @@ private fun Crest(
|
|||||||
val ringColor =
|
val ringColor =
|
||||||
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
|
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||||
else CastarrColors.accent
|
else CastarrColors.accent
|
||||||
Canvas(Modifier.size(30.dp)) {
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
val grow = phase.coerceAtMost(0.7f) / 0.7f
|
val grow = phase.coerceAtMost(0.75f) / 0.75f
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = ringColor.copy(alpha = (1f - grow) * 0.9f),
|
color = ringColor.copy(alpha = (1f - grow) * 0.85f),
|
||||||
radius = size.minDimension * (0.33f + grow * 0.17f),
|
radius = size.minDimension * (0.34f + grow * 0.15f),
|
||||||
style = Stroke(width = 2.dp.toPx()),
|
style = Stroke(width = 2.dp.toPx()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -507,7 +520,8 @@ private fun Crest(
|
|||||||
model = "file:///android_asset/crests/${team.key}.png",
|
model = "file:///android_asset/crests/${team.key}.png",
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize(),
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
loading = { ShieldFallback(team) },
|
loading = { ShieldFallback(team) },
|
||||||
error = { ShieldFallback(team) },
|
error = { ShieldFallback(team) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ object UpdateChecker {
|
|||||||
|
|
||||||
private var apkUrl: String = ""
|
private var apkUrl: String = ""
|
||||||
|
|
||||||
|
/** Guards against a second click landing on the same download. */
|
||||||
|
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
|
||||||
/** Returns the newer version tag, or null when current. Never throws. */
|
/** Returns the newer version tag, or null when current. Never throws. */
|
||||||
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -54,35 +57,69 @@ object UpdateChecker {
|
|||||||
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
|
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Downloads the APK and hands it to the package installer. */
|
/**
|
||||||
|
* Downloads the APK and hands it to the package installer.
|
||||||
|
*
|
||||||
|
* The download lands in a .part file that is only renamed once the byte
|
||||||
|
* count matches what the server announced. Handing a half-written APK to
|
||||||
|
* the installer leaves it sitting on a spinner with nothing to report,
|
||||||
|
* which is indistinguishable from a hang.
|
||||||
|
*/
|
||||||
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
|
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
// A second click while the first download runs would have two
|
||||||
if (apkUrl.isEmpty()) check(state)
|
// writers on one file, and the installer reads whichever half won.
|
||||||
require(apkUrl.isNotEmpty()) { "no update available" }
|
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
|
||||||
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
try {
|
||||||
val file = File(dir, "castarr-update.apk")
|
runCatching {
|
||||||
(URL(apkUrl).openConnection() as HttpURLConnection).run {
|
if (apkUrl.isEmpty()) check(state)
|
||||||
connectTimeout = 15_000
|
require(apkUrl.isNotEmpty()) { "no update available" }
|
||||||
readTimeout = 120_000
|
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||||
instanceFollowRedirects = true
|
val file = File(dir, "castarr-update.apk")
|
||||||
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
|
val part = File(dir, "castarr-update.apk.part")
|
||||||
disconnect()
|
part.delete()
|
||||||
|
val connection = URL(apkUrl).openConnection() as HttpURLConnection
|
||||||
|
val expected = try {
|
||||||
|
connection.connectTimeout = 15_000
|
||||||
|
connection.readTimeout = 120_000
|
||||||
|
connection.instanceFollowRedirects = true
|
||||||
|
val code = connection.responseCode
|
||||||
|
require(code == HttpURLConnection.HTTP_OK) { "http $code" }
|
||||||
|
val announced = connection.contentLengthLong
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
part.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
announced
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
require(part.length() > 0) { "empty download" }
|
||||||
|
require(expected <= 0 || part.length() == expected) {
|
||||||
|
"truncated: ${part.length()} of $expected"
|
||||||
|
}
|
||||||
|
file.delete()
|
||||||
|
require(part.renameTo(file)) { "rename failed" }
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
|
||||||
|
)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Launching from the IO dispatcher stalled the installer on
|
||||||
|
// the first attempt — activities start from the main thread.
|
||||||
|
withContext(Dispatchers.Main) { context.startActivity(intent) }
|
||||||
|
null
|
||||||
|
}.getOrElse {
|
||||||
|
Log.w(TAG, "install failed: ${it.javaClass.simpleName}: ${it.message}")
|
||||||
|
File(context.cacheDir, "updates/castarr-update.apk.part").delete()
|
||||||
|
"Update fehlgeschlagen — später erneut versuchen"
|
||||||
}
|
}
|
||||||
val uri = FileProvider.getUriForFile(
|
} finally {
|
||||||
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
|
busy.set(false)
|
||||||
)
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
// Launching from the IO dispatcher stalled the installer on
|
|
||||||
// the first attempt — activities start from the main thread.
|
|
||||||
withContext(Dispatchers.Main) { context.startActivity(intent) }
|
|
||||||
null
|
|
||||||
}.getOrElse {
|
|
||||||
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
|
|
||||||
"Update fehlgeschlagen — später erneut versuchen"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
tests/helpers/emulator.sh
Executable file
70
tests/helpers/emulator.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Headless Google-TV emulator for test runs.
|
||||||
|
#
|
||||||
|
# The AVD keeps a "default_boot" snapshot, so a start restores a booted
|
||||||
|
# system in seconds instead of cold-booting for minutes. "stop" refreshes
|
||||||
|
# that snapshot before killing, so the next start stays fast.
|
||||||
|
#
|
||||||
|
# tests/helpers/emulator.sh start # boot (or restore) and wait
|
||||||
|
# tests/helpers/emulator.sh stop # snapshot, then kill
|
||||||
|
# tests/helpers/emulator.sh status
|
||||||
|
# tests/helpers/emulator.sh install <apk>
|
||||||
|
# tests/helpers/emulator.sh shot <name> # screenshot into tests/runs/screenshots
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AVD="${CASTARR_AVD:-castarr-googletv}"
|
||||||
|
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
|
||||||
|
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
|
||||||
|
ADB="$SDK/platform-tools/adb"
|
||||||
|
EMULATOR="$SDK/emulator/emulator"
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
RUNS="$ROOT/tests/runs"
|
||||||
|
LOG="$RUNS/emulator.log"
|
||||||
|
|
||||||
|
booted() { [ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; }
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if booted; then echo "already up: $SERIAL"; return 0; fi
|
||||||
|
mkdir -p "$RUNS"
|
||||||
|
# No -no-snapshot here: that is what makes a start slow.
|
||||||
|
nohup "$EMULATOR" -avd "$AVD" \
|
||||||
|
-no-window -no-audio -no-boot-anim \
|
||||||
|
-gpu swiftshader_indirect \
|
||||||
|
>"$LOG" 2>&1 &
|
||||||
|
local start_ts=$SECONDS
|
||||||
|
"$ADB" -s "$SERIAL" wait-for-device
|
||||||
|
for _ in $(seq 1 180); do
|
||||||
|
booted && { echo "up after $((SECONDS - start_ts))s"; return 0; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "timeout waiting for boot; see $LOG" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if ! pgrep -f "qemu-system-.*-avd $AVD" >/dev/null 2>&1 && ! booted; then
|
||||||
|
echo "not running"; return 0
|
||||||
|
fi
|
||||||
|
# Refresh the snapshot so the next start is a restore, not a cold boot.
|
||||||
|
"$ADB" -s "$SERIAL" emu avd snapshot save default_boot
|
||||||
|
"$ADB" -s "$SERIAL" emu kill
|
||||||
|
echo "stopped, snapshot saved"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
start) start ;;
|
||||||
|
stop) stop ;;
|
||||||
|
status) booted && echo "up: $SERIAL" || echo "down" ;;
|
||||||
|
install)
|
||||||
|
[ $# -ge 2 ] || { echo "usage: $0 install <apk>" >&2; exit 2; }
|
||||||
|
"$ADB" -s "$SERIAL" install -r "$2"
|
||||||
|
;;
|
||||||
|
shot)
|
||||||
|
name="${2:-shot}"
|
||||||
|
mkdir -p "$RUNS/screenshots"
|
||||||
|
"$ADB" -s "$SERIAL" exec-out screencap -p > "$RUNS/screenshots/$name.png"
|
||||||
|
echo "$RUNS/screenshots/$name.png"
|
||||||
|
;;
|
||||||
|
*) echo "usage: $0 {start|stop|status|install <apk>|shot <name>}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -27,6 +27,30 @@ if os.path.exists(URLS):
|
|||||||
kotlin = open(SRC, encoding="utf-8").read()
|
kotlin = open(SRC, encoding="utf-8").read()
|
||||||
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
|
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
|
||||||
clubs = []
|
clubs = []
|
||||||
|
# Wikipedia serves ~330px thumbnails. The rail draws them at 20dp, which on a
|
||||||
|
# 1080p TV is 40px -- an 8x downscale that Android does with a cheap filter,
|
||||||
|
# and the edges come out ragged. Resampling once here with a proper filter
|
||||||
|
# fixes that and shrinks the APK.
|
||||||
|
CREST_PX = 128
|
||||||
|
|
||||||
|
|
||||||
|
def downscale(data: bytes) -> bytes:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
return data
|
||||||
|
import io
|
||||||
|
|
||||||
|
image = Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
|
if max(image.size) <= CREST_PX:
|
||||||
|
return data
|
||||||
|
scale = CREST_PX / max(image.size)
|
||||||
|
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
|
||||||
|
out = io.BytesIO()
|
||||||
|
image.resize(size, Image.LANCZOS).save(out, format="PNG", optimize=True)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
|
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
|
||||||
clubs.append(raw)
|
clubs.append(raw)
|
||||||
# an explicit article = "..." wins over the club name
|
# an explicit article = "..." wins over the club name
|
||||||
@@ -52,7 +76,7 @@ for key, full_name in clubs:
|
|||||||
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
|
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
|
||||||
data = r.read()
|
data = r.read()
|
||||||
with open(target, "wb") as f:
|
with open(target, "wb") as f:
|
||||||
f.write(data)
|
f.write(downscale(data))
|
||||||
fetched += 1
|
fetched += 1
|
||||||
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
||||||
print(f" {key}: {exc}", file=sys.stderr)
|
print(f" {key}: {exc}", file=sys.stderr)
|
||||||
|
|||||||
Reference in New Issue
Block a user