diff --git a/config.json b/config.json
index ccf3ebf..53f4424 100644
--- a/config.json
+++ b/config.json
@@ -91,6 +91,9 @@
"fx.gbpusd": {"norm": {"mode": "zscore", "window": 200}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "GBP/USD (ECB fix)"},
"fx.usdjpy": {"norm": {"mode": "zscore", "window": 200}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "USD/JPY (ECB fix)"},
"fx.audusd": {"norm": {"mode": "zscore", "window": 200}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "AUD/USD (ECB fix)"},
+ "sport.events_today": {"norm": {"mode": "minmax", "window": 50}, "label": "matches today"},
+ "sport.inplay": {"norm": {"mode": "minmax", "window": 200}, "filter": {"min_cutoff": 0.3, "beta": 0.01}, "label": "matches in play"},
+ "sport.kickoff": {"type": "event", "norm": {"lo": 0, "hi": 5}, "decay": 3.0, "label": "kickoff!"},
"quake.event": {"type": "event", "norm": {"lo": 0, "hi": 7}, "decay": 3.5, "label": "earthquake"},
"quake.depth": {"norm": {"lo": 0, "hi": 300}, "label": "quake depth"},
"wiki.edit.event": {"type": "event", "norm": {"lo": 0, "hi": 3000}, "attack": 0.005, "decay": 0.25, "label": "wiki edit"},
diff --git a/run.py b/run.py
index b79d013..a2f4f0d 100644
--- a/run.py
+++ b/run.py
@@ -129,6 +129,8 @@ def main():
help="also record everything to godstrument.db")
ap.add_argument("--dealgod", action="store_true",
help="also run the dealgod market worker (reads the warehouse)")
+ ap.add_argument("--sports", action="store_true",
+ help="also run the sports worker (TheSportsDB free tier — rate-limited shared key, so opt-in)")
ap.add_argument("--no-browser", action="store_true")
args = ap.parse_args()
@@ -164,6 +166,10 @@ def main():
if start_worker("workers/world_dealgod.py"):
print(" dealgod market worker -> reading the warehouse")
+ if args.sports:
+ if start_worker("workers/world_sport.py"):
+ print(" sports worker -> the world's fixtures (thesportsdb, opt-in)")
+
if args.record:
if start_worker("recorder.py"):
print(" recording -> godstrument.db")
diff --git a/viz/index.html b/viz/index.html
index 68a04dd..5bc3318 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -555,6 +555,8 @@
The trading desk — more markets, and the market's mood as signals. The same Binance tap now carries ETH and SOL (crypto.eth, crypto.sol, each with its own velocity) and — because Binance quotes the euro itself — EUR/USD live (fx.eurusd, on a steady one-second pulse). The slower currencies arrive as ECB daily reference rates: fx.gbpusd, fx.usdjpy, fx.audusd — glacial, tectonic feeds like the national debt; drones, not melodies. And Bitcoin now speaks its own technical analysis, computed on five-second closes so it is stable no matter how fast the trades storm in: crypto.macd is the MACD line (fast EMA minus slow — momentum, z-scored so above-half means a bullish push), crypto.rsi is Wilder's RSI(14) (a natural mean-reverting wave: near 1 overbought, near 0 oversold — practically a market-driven LFO), and the cross bells: crypto.cross_up fires a single impulse the moment the fast average climbs over the slow (a golden cross in miniature) and crypto.cross_down when it falls through — patch them to a bell, a chord, the reverb throwing open, and the market's turning points become events you hear happen. Every one of these routes through the Spirits like any money: to the solar plexus, to Hod, to the dwarves in Nidavellir, to ᚠ fehu — wealth is strife among kinsmen.
+The stadium — the world at play (opt-in). Start the hub with --sports and TheSportsDB's free tier joins the orchestra. It stays off until invited because the free key is shared and rate-limited (30 calls a minute) — so the worker is polite by design: the day's fixtures arrive in a few calls per hour, and everything else is computed at home. sport.events_today counts the planet's fixtures; sport.inplay is how many matches are being played right now — the world's stadium lights as a stepped curve climbing through the evenings; and sport.kickoff fires the instant any match begins, swelling when many begin together — three o'clock on an English Saturday rings like a carillon. Somewhere, a whistle blows; patch it to a bell and hear the world step onto the pitch. It routes where play belongs: Netzach — Victory — on the Tree, the fifth house Leo on the wheel (the house of games), the funeral games of Lughnasadh's great fair, ᚹ wunjo the joy-rune, ☱ duì the joyous lake. (Live scores are the paid tier; the schedule is free, and the kickoff was always the musical part.)
+
market.velocity / market.turnover — your dealgod market. Your own deal-flow, read from the basegod warehouse (dealgod.price_events, ~4M rows) over the tailnet — a single read-only aggregate query, no writes. Binned into an activity curve (velocity) and an average-price curve (turnover), replayed compressed and jog-scrubbable. Both are pre-normalized 0→1. Character: a replay with a playhead, so it's scratchable like a record. Your market drives the hats (amt 0.7) and nudges tempo (amt 0.2), mirroring BTC; pricier gear moving = more grit (turnover → saturation, amt 0.5, exp); and your market plays the lead (turnover → lead.note, G dorian, root 55, 2 octaves). This is the one voice that is yours — your commerce, given a melody in a mode with a little melancholy built in.
@@ -924,7 +926,7 @@
♪ (top-right) — wake the sound. Browsers hold audio until a human asks. This is the ask. One click and the in-browser synth spins up, and the planet — already flowing as data — becomes audible.
The godtime dial (top) — set the pulse. Drag it to choose a tempo from 40 to 180 BPM, with the real planets of tonight's sky wheeling around it as you turn. Click the dial to lock the tempo where you left it — otherwise the data itself keeps nudging it (bitcoin's velocity, the dealgod market, inflation, each with a small hand on the clock).
Press P — performance mode. Everything that is not the world vanishes: the wordmark, the header, the counts, the godtime readout, the ticker, every button and chip — pure visuals, with only a rotating line of dispatches from the world (bitcoin at $62,902 · the moon is 81% lit · mercury is retrograde · M4.2 earthquake) rising for the audience. Put the console on the projector, hit P, and conduct. (Want even the labels and dispatches gone? Z is zen — nothing but the sky.)
-Press W — the sky wheel. The zodiac rises behind the whole instrument, huge and faint: the twelve signs in their element colours, the living planets on the rim exactly where they stand tonight (the Sun in Cancer in early July, wherever the Moon has wandered to), and the sky's aspects strung as chords across the wheel — red for the hard angles, green for the soft, gold for the meetings. Cast a chart (✨ your stars) and the layer deepens: your whole-sign houses divide the wheel, the ASC and MC stand in gold, your natal planets sit as gold ghosts on the inner ring, and transit chords strike them as the sky moves. The same wheel the zodiac skin draws, now living under the main view. W again to put it away; it remembers.
+Press W — the sky wheel. The zodiac rises behind the whole instrument, huge and faint, drawn the way a natal chart is drawn: a sign band with the twelve glyphs in their element colours and degree ticks running the rim, the living planets just inside it exactly where they stand tonight, the sky's aspects strung as chords across the wheel — red for the hard angles, green for the soft, gold for the meetings — and the sign the Sun is walking right now lit in its own colour (Cancer in early July), so one glance says where in the year you are. Cast a chart (✨ your stars) and the layer deepens: your whole-sign houses divide the wheel, the ASC and MC stand in gold, your natal planets sit as gold ghosts on the inner ring, and transit chords strike them as the sky moves. The same wheel the zodiac skin draws, now living under the main view. W again to put it away; it remembers.
Press Z — zen. Every word vanishes: labels, readouts, panels, the header, the ticker, the dial — all of it — and only the visuals remain, full screen. The spheres, the cables, the shockwaves, the sky wheel if it's up, still moving to the world; nothing left to read, everything left to watch. Z or esc brings the words back.
The keyboard is yours — F1–F10 and beyond. A default set of function keys is ready to fly: F1 the grimoire, F2 play/stop the synth, F3 the skin view, F4 next skin, F5 cycle mood, F6 tracks, F7 performance mode, F8 lock the godtime, F9 the earth echo, F10 the stage. But every key is remappable: right-click the sky → ⌨ keys to bind any key to any action, or map a key from a voice's own right-click menu. Want a and s to raise and lower a drone's level like a fader? Right-click that voice → map a key → raise its level, press a; again for lower, press s — now they nudge it live, hold to sweep. Your whole flow, under your fingers.
Press C — chakra view. The sources leave their column and take up their stations on the subtle body: seven wheels on a spine of light, crown to root, each feed orbiting the chakra it belongs to — the sky's machinery at the crown, the omens at the third eye, the species talking at the throat, weather and air at the heart, fire and money at the solar plexus, birth and water at the sacral, quakes and debt and the mortal weight at the root. Each wheel glows with the live average of its members and spins its traditional petals, counter-rotating wheel to wheel. Drag empty space to spin the whole body 360°, drag up/down to tilt, flick and let momentum carry it. Everything still works in there: click a node to mute it, drag it onto a voice to patch, shift-click for its tab. C again and the sources glide home.
@@ -1185,6 +1187,9 @@
"fx.gbpusd": "GBP/USD — cable, from the ECB daily fix (glacial: a drone, not a melody)",
"fx.usdjpy": "USD/JPY — the yen, from the ECB daily fix",
"fx.audusd": "AUD/USD — the aussie, from the ECB daily fix",
+ "sport.events_today": "how many matches the planet plays today (TheSportsDB — opt-in: run with --sports)",
+ "sport.inplay": "matches being played right now, worldwide — the world's stadium lights as a curve",
+ "sport.kickoff": "somewhere, a whistle blows — fires at every kickoff, louder when many start at once",
"filter.cutoff": "master brightness — opens & closes the whole sound",
"reverb.size": "the size of the space everything sits in",
"delay.feedback": "echo — how much the sound repeats and smears",
@@ -2943,7 +2948,7 @@
// throat — the species talking to itself
if (["wiki","net","news","social","edit"].includes(p)) return "throat";
// solar plexus — fire, will, money
- if (["crypto","market","stock","btc","eth","fx","dealgod","fire","me"].includes(p) || /inflation/.test(k)) return "solar";
+ if (["crypto","market","stock","btc","eth","fx","dealgod","fire","me","sport"].includes(p) || /inflation/.test(k)) return "solar";
// sacral — water, creation, birth
if (/birth|fertil|precip|humid|rain|tide|moon|pop/.test(k)) return "sacral";
// root — ground, survival, the heavy and the mortal
@@ -3009,7 +3014,7 @@
if (["wiki","net","news","social","edit","econ","debt"].includes(p) || // V · abstract thought & money-as-idea
/inflation/.test(k) || k === "market.turnover" || k === "crypto.price") return "mental";
if (/vel$|velocity/.test(k) || // VI · desire, fire, felt sensation
- ["fire","crypto","fx","market","light","ambient","audio","mic","me"].includes(p)) return "astral";
+ ["fire","crypto","fx","market","light","ambient","audio","mic","me","sport"].includes(p)) return "astral";
return "physical"; // VII · the dense body of the world
}
@@ -3055,6 +3060,7 @@
if (k === "world.lifeexp" || k === "world.poverty" || k === "astro.harmony") return "chesed";
if (k === "astro.mars" || p === "fire" || k === "quake.event" || k === "astro.tension" || k === "world.hunger") return "gevurah";
if (/^sky\./.test(k)) return "tiferet"; // the solar heart
+ if (p === "sport") return "netzach"; // Netzach is Victory — the games live here
if (/season|almanac\.(element|fertile)/.test(k)) return "netzach"; // Venus: nature's rhythms
if (["wiki","market","crypto","fx","planes","econ","me"].includes(p) || /retro/.test(k)) return "hod"; // Mercury: mind & number
if (/^astro\.moon|almanac\.dec|ambient|light\./.test(k)) return "yesod"; // Levanah: the astral machinery
@@ -3299,7 +3305,7 @@
if (p === "me" || p === "fx" || k === "market.turnover" || k === "crypto.price") return "taurus"; // holdings & value: the second house
if (p === "wiki" || /mercury_retro/.test(k)) return "gemini"; // the word; Mercury's own condition
if (p === "weather" || k === "almanac.dec") return "cancer"; // the home sky
- if (p === "sun" || /^sky\.|light|ambient/.test(k)) return "leo"; // the light itself
+ if (p === "sun" || p === "sport" || /^sky\.|light|ambient/.test(k)) return "leo"; // the light itself; the fifth house holds the games
if (/aqi|pm25|lifeexp/.test(k)) return "virgo"; // health
if (p === "syn" || /harmony|season/.test(k)) return "libra"; // partnership; balance, equinox
if (/deaths|debt|tension/.test(k)) return "scorpio"; // the deep and the owed
@@ -3333,17 +3339,39 @@
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, R * 0.55, 0, Math.PI * 2); ctx.stroke();
- // as a layer the skin's station nodes aren't there, so the wheel carries
- // its own signs: boundary ticks and the twelve glyphs, in element colour
- if (layerBox) for (let i2 = 0; i2 < 12; i2++) {
- const t1 = lonXY(i2 * 30, R - 6), t2 = lonXY(i2 * 30, R + 5);
- ctx.beginPath(); ctx.moveTo(t1[0], t1[1]); ctx.lineTo(t2[0], t2[1]);
- ctx.strokeStyle = "rgba(200,212,240,0.14)"; ctx.lineWidth = 1; ctx.stroke();
- const gp = lonXY(i2 * 30 + 15, R + 17);
- ctx.fillStyle = rgba(SIGNS[i2].col, 0.55);
- ctx.font = "13px 'Helvetica Neue', Arial, sans-serif";
- ctx.fillText(SIGNS[i2].label.split(" ")[0] + "︎", gp[0], gp[1]); // ︎: text glyph, not emoji
+ // as a layer the skin's station nodes aren't there, so the wheel reads like
+ // a natal chart: a sign band with the glyphs inside it, degree ticks, and
+ // the sign the Sun is walking right now lit in its own colour
+ const bandR = R * 0.88;
+ if (layerBox) {
+ ctx.beginPath(); ctx.arc(cx, cy, bandR, 0, Math.PI * 2);
+ ctx.strokeStyle = "rgba(200,212,240,0.20)"; ctx.lineWidth = 1; ctx.stroke();
+ for (let d = 0; d < 360; d += 5) { // degree ticks along the band's inner edge
+ if (d % 30 === 0) continue;
+ const t1 = lonXY(d, bandR), t2 = lonXY(d, bandR + (d % 10 === 0 ? 7 : 3.5));
+ ctx.beginPath(); ctx.moveTo(t1[0], t1[1]); ctx.lineTo(t2[0], t2[1]);
+ ctx.strokeStyle = "rgba(200,212,240,0.10)"; ctx.lineWidth = 1; ctx.stroke();
+ }
+ const sunN = sources.get("astro.sun_lon");
+ const sunSign = sunN && typeof sunN.raw === "number"
+ ? Math.floor((((sunN.raw % 360) + 360) % 360) / 30) : -1;
+ for (let i2 = 0; i2 < 12; i2++) {
+ const t1 = lonXY(i2 * 30, bandR), t2 = lonXY(i2 * 30, R);
+ ctx.beginPath(); ctx.moveTo(t1[0], t1[1]); ctx.lineTo(t2[0], t2[1]);
+ ctx.strokeStyle = "rgba(200,212,240,0.16)"; ctx.lineWidth = 1; ctx.stroke();
+ const here = i2 === sunSign; // the month we are inside right now
+ if (here) { // the current sign's slice of band, aglow
+ const a0 = (180 + i2 * 30) * Math.PI / 180;
+ ctx.beginPath(); ctx.arc(cx, cy, (bandR + R) / 2, -(a0 + Math.PI / 6), -a0);
+ ctx.strokeStyle = rgba(SIGNS[i2].col, 0.16); ctx.lineWidth = R - bandR; ctx.stroke();
+ }
+ const gp = lonXY(i2 * 30 + 15, (bandR + R) / 2);
+ ctx.fillStyle = rgba(SIGNS[i2].col, here ? 0.95 : 0.5);
+ ctx.font = (here ? "15px" : "13px") + " 'Helvetica Neue', Arial, sans-serif";
+ ctx.fillText(SIGNS[i2].label.split(" ")[0] + "︎", gp[0], gp[1]); // FE0E: text glyph, not emoji
+ }
}
+ const pR = layerBox ? R * 0.80 : R; // as a chart the planets sit inside the band
// whole-sign houses + the ascendant, when a chart with a birthplace is cast
if (natalActive && natalActive.asc != null) {
@@ -3385,7 +3413,7 @@
for (let i2 = 0; i2 < live.length; i2++) for (let j2 = i2 + 1; j2 < live.length; j2++) {
const h = aspectHit(live[i2].lon, live[j2].lon, /sun|moon/.test(live[i2].k + live[j2].k));
if (!h || h.s < 0.05) continue;
- const p1 = lonXY(live[i2].lon, R), p2 = lonXY(live[j2].lon, R);
+ const p1 = lonXY(live[i2].lon, pR), p2 = lonXY(live[j2].lon, pR);
ctx.beginPath(); ctx.moveTo(p1[0], p1[1]); ctx.lineTo(p2[0], p2[1]);
ctx.strokeStyle = KINDCOL[h.kind] + (0.15 + h.s * 0.6).toFixed(3) + ")";
ctx.lineWidth = 0.8 + h.s * 2.2; ctx.stroke();
@@ -3416,7 +3444,7 @@
for (const lv of live) {
const h = aspectHit(lv.lon, nl, p2 === "sun" || p2 === "moon" || /sun|moon/.test(lv.k));
if (!h || h.s < 0.08) continue;
- const q1 = lonXY(lv.lon, R), q2 = lonXY(nl, R * 0.55);
+ const q1 = lonXY(lv.lon, pR), q2 = lonXY(nl, R * 0.55);
ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.moveTo(q1[0], q1[1]); ctx.lineTo(q2[0], q2[1]);
ctx.strokeStyle = KINDCOL[h.kind] + (0.15 + h.s * 0.55).toFixed(3) + ")";
@@ -3431,8 +3459,8 @@
}
// the living planets on the rim
for (const lv of live) {
- const pp = lonXY(lv.lon, R);
- ctx.font = "13px 'Helvetica Neue', Arial, sans-serif";
+ const pp = lonXY(lv.lon, pR);
+ ctx.font = (layerBox ? "16px" : "13px") + " 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = "rgba(150,190,255,0.35)";
ctx.fillText(lv.glyph, pp[0], pp[1] + 0.5);
ctx.fillStyle = "rgba(224,234,255,0.95)";
@@ -3489,7 +3517,7 @@
if (p === "planes" || p === "iss") return "raidho"; // the ride
if (p === "fire" || k === "weather.temp") return "kenaz"; // the torch
if (/market\./.test(k)) return "gebo"; // exchange is the gift
- if (k === "astro.harmony") return "wunjo";
+ if (k === "astro.harmony" || p === "sport") return "wunjo"; // joy — the crowd's rune
if (/pressure|tension|wind/.test(k)) return "hagalaz"; // the hail-storm sky
if (/hunger|poverty|debt\.vel/.test(k)) return "nauthiz"; // need, the narrow band
if (/retro/.test(k)) return "isa"; // the sky standing still (licence)
@@ -3542,7 +3570,7 @@
if (/births|fertile/.test(k) || p === "natal") return "imbolc"; // first stirrings; your birth
if (p === "fire" || ["hand", "tof", "touch", "body", "syn"].includes(p)) return "beltane"; // the fires, the dance, the union
if (p === "sun" || /^sky\./.test(k)) return "litha"; // the sun at its height
- if (["wiki", "audio", "market", "me", "crypto", "fx", "planes", "iss"].includes(p)) return "lughnasadh"; // the great fair
+ if (["wiki", "audio", "market", "me", "crypto", "fx", "planes", "iss", "sport"].includes(p)) return "lughnasadh"; // the great fair — Lugh's own funeral games
return "mabon"; // the reckoning of the year
}
function drawYearOverlay(t, g) { // the sun walking the year, live
@@ -3609,8 +3637,8 @@
if (/harmony|fertile|births|light\.|ambient/.test(k)) return "white"; // silence, pregnant with possibility
if (/deaths|debt|poverty/.test(k)) return "black"; // the silence with no future
if (p === "body" || p === "fire" || k === "quake.event" || k === "astro.mars") return "red"; // heartbeat & flame: the drum
- if (["wiki", "net", "news", "social", "edit", "crypto", "fx", "market", "econ", "me"].includes(p) ||
- /inflation/.test(k)) return "yellow"; // the trumpet, blown louder and louder
+ if (["wiki", "net", "news", "social", "edit", "crypto", "fx", "market", "econ", "me", "sport"].includes(p) ||
+ /inflation/.test(k)) return "yellow"; // the trumpet, blown louder and louder — the stadium too
if (p === "clock" || p === "audio" || /season|zodiac|hour|lifeexp/.test(k)) return "orange"; // the bell, rung on the hour
if (["weather", "air", "planes", "plane"].includes(p) || /temp|wind|pressure|humid|rain|precip/.test(k)) return "green"; // nature at rest
if (p === "sun" || p === "sky" || p === "iss" || p === "space" || /_lon|moon|elev|aurora|daynight/.test(k)) return "blue"; // the heavenly circle
@@ -3712,7 +3740,7 @@
if (p === "quake" || p === "body" || k === "astro.tension") return "zhen"; // the shock; the pulse (licence: the foot that moves you)
if (p === "fire" || p === "sun" || /light\.|ambient|aurora|elev/.test(k)) return "li"; // fire, the sun, lightning, the eye
if (/rain|precip|humid|tide/.test(k) || k === "astro.moon" || p === "debt" || /inflation/.test(k)) return "kan"; // water, the moon; the abyss (licence: the debt)
- if (["wiki", "net", "news", "social", "edit", "audio"].includes(p) || k === "astro.harmony" || p === "syn") return "dui"; // the mouth and tongue; joy
+ if (["wiki", "net", "news", "social", "edit", "audio", "sport"].includes(p) || k === "astro.harmony" || p === "syn") return "dui"; // the mouth and tongue; joy — the crowd
if (["weather", "air", "planes", "plane"].includes(p) || /wind|pressure|temp/.test(k)) return "xun"; // what rides the wind
if (p === "iss" || p === "space" || p === "natal" || /^sky\.|_lon/.test(k) || p === "astro") return "qian"; // the heavens
if (["crypto", "fx", "market", "econ", "me"].includes(p)) return "qian"; // "Qian is jade, is gold" (licence: the markets)
diff --git a/workers/world_sport.py b/workers/world_sport.py
new file mode 100644
index 0000000..70ab60b
--- /dev/null
+++ b/workers/world_sport.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Somewhere, a whistle blows — the world's sporting day via TheSportsDB.
+
+OPT-IN (run.py --sports): the free tier allows 30 requests a minute on a
+shared key and limited leagues, so this worker stays off until invited. It is
+also polite by design: the day's schedule arrives in a few calls per hour and
+everything else is computed at home, no live polling —
+
+ sport.events_today how many fixtures the planet plays today
+ sport.inplay matches being played right now (a stepped curve
+ climbing through the world's evenings)
+ sport.kickoff an impulse the moment any match begins, its magnitude
+ the number kicking off together — three o'clock on an
+ English Saturday rings like a carillon
+
+Live scores are the paid tier; the schedule is free, and kickoffs are the
+musical part anyway.
+"""
+import argparse
+import datetime as dt
+import json
+import time
+import urllib.parse
+import urllib.request
+
+from pythonosc.udp_client import SimpleUDPClient
+
+NAME = "world_sport"
+API = "https://www.thesportsdb.com/api/v1/json/{key}/eventsday.php?d={day}&s={sport}"
+SPORTS = ["Soccer", "Basketball", "Ice Hockey"]
+POLL_S = 900.0 # schedule refresh: 3 calls / 15 min — miles under the limit
+TICK_S = 10.0 # local clock: kickoff detection costs no API calls
+INPLAY_S = 2 * 3600 # a match "in play" for ~2h from kickoff
+USER_AGENT = "godstrument/1.0 (+world_sport)"
+
+
+def fetch_day(key, sport, day):
+ url = API.format(key=urllib.parse.quote(key), day=day, sport=urllib.parse.quote(sport))
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read().decode()).get("events") or []
+
+
+def kickoff_times(events):
+ """-> sorted UTC epoch seconds; events without a timestamp are skipped."""
+ out = []
+ for e in events:
+ ts = e.get("strTimestamp")
+ if not ts:
+ continue
+ try:
+ d = dt.datetime.fromisoformat(ts)
+ if d.tzinfo is None: # thesportsdb timestamps are UTC
+ d = d.replace(tzinfo=dt.timezone.utc)
+ out.append(d.timestamp())
+ except ValueError:
+ continue
+ return sorted(out)
+
+
+def inplay(times, now):
+ return sum(1 for t in times if t <= now < t + INPLAY_S)
+
+
+def kicked_between(times, t0, t1):
+ return sum(1 for t in times if t0 < t <= t1)
+
+
+def refresh(key):
+ day = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d")
+ total, times = 0, []
+ for sport in SPORTS:
+ ev = fetch_day(key, sport, day)
+ total += len(ev)
+ times += kickoff_times(ev)
+ time.sleep(2.1) # 30/min shared key: stay courteous
+ return total, sorted(times)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="TheSportsDB fixtures -> OSC (opt-in)")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=9000)
+ parser.add_argument("--key", default="123", help="thesportsdb API key (free shared key: 123)")
+ parser.add_argument("--once", action="store_true", help="one fetch+emit, then exit (doubles as the self-check)")
+ args = parser.parse_args()
+ client = SimpleUDPClient(args.host, args.port)
+ print(f"[{NAME}] emitting /gs/sport/events_today,/gs/sport/inplay,/gs/sport/kickoff "
+ f"· schedule every {POLL_S:g}s, whistle watched every {TICK_S:g}s")
+
+ total, times, next_poll, last_tick = 0, [], 0.0, time.time()
+ while True:
+ now = time.time()
+ if now >= next_poll:
+ try:
+ total, times = refresh(args.key)
+ client.send_message("/gs/sport/events_today", float(total))
+ print(f"[{NAME}] {total} fixtures today, {inplay(times, now)} in play")
+ next_poll = now + POLL_S
+ except Exception as e: # transient network / parse error — keep going
+ print(f"[{NAME}] refresh error: {e}; retrying in {POLL_S / 10:g}s")
+ next_poll = now + POLL_S / 10
+ k = kicked_between(times, last_tick, now)
+ if k:
+ client.send_message("/gs/sport/kickoff", float(k))
+ print(f"[{NAME}] the whistle blows — {k} match{'es' if k > 1 else ''} kicking off")
+ client.send_message("/gs/sport/inplay", float(inplay(times, now)))
+ last_tick = now
+ if args.once:
+ assert total >= 0 and all(isinstance(t, float) for t in times), "nonsense schedule"
+ print(f"[{NAME}] once: ok — {total} fixtures, {len(times)} timestamped")
+ return
+ time.sleep(TICK_S)
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ pass