touchline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +67 -0
- package/bin/touchline.js +57 -0
- package/package.json +31 -0
- package/src/touchline/__init__.py +4 -0
- package/src/touchline/__main__.py +5 -0
- package/src/touchline/api.py +357 -0
- package/src/touchline/app.py +164 -0
- package/src/touchline/demo.py +83 -0
- package/src/touchline/model.py +93 -0
- package/src/touchline/ui.py +434 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hamed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Touchline
|
|
2
|
+
|
|
3
|
+
A live football match centre designed for the terminal. Touchline reads the same JSON feeds as the supplied SVT/eNetScores widget and turns them into a fast, keyboard-driven TUI.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
## Run it
|
|
9
|
+
|
|
10
|
+
No clone or installation is needed:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx touchline
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Touchline requires Node.js 18+ for `npx` and Python 3.10+ for the TUI runtime. It has no npm or Python package dependencies.
|
|
17
|
+
|
|
18
|
+
To run the source checkout directly:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd /Users/hamed/dev/claude-tools/touchline
|
|
22
|
+
PYTHONPATH=src python3 -m touchline
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The Spain–Argentina World Cup final is the default match. A different SVT iframe URL or direct eNetScores widget URL can be passed as the first argument:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx touchline 'https://widget.enetscores.com/CLIENT/ev/EVENT_ID/lng/sv'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Commentary is translated to English by default, regardless of the language embedded in the source URL. Use `--language sv` to follow the original Swedish feed instead.
|
|
32
|
+
|
|
33
|
+
## Views and keys
|
|
34
|
+
|
|
35
|
+
| Key | Action |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `1`–`8` | Overview, live feed, team stats, lineups, formation, player stats, H2H, bracket |
|
|
38
|
+
| `←` / `→` or `h` / `l` | Previous/next view |
|
|
39
|
+
| `j` / `k` | Scroll |
|
|
40
|
+
| `r` | Refresh immediately |
|
|
41
|
+
| `q` | Quit |
|
|
42
|
+
|
|
43
|
+
The live score, commentary, team stats and player stats refresh every 10 seconds. Lineups, formations, H2H and the bracket are fetched once on startup because they rarely change. The feed itself is cached upstream for five seconds, so `--refresh 5` is the fastest useful setting.
|
|
44
|
+
|
|
45
|
+
## Preview and diagnostics
|
|
46
|
+
|
|
47
|
+
Render a non-interactive frame without hitting the live feed:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx touchline --demo --snapshot --no-color
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Useful options:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
--refresh SECONDS polling interval, minimum 5 seconds
|
|
57
|
+
--language, -L CODE commentary language; English by default
|
|
58
|
+
--demo use built-in sample data
|
|
59
|
+
--snapshot render once and exit
|
|
60
|
+
--tab 1..8 choose the initial view
|
|
61
|
+
--width / --height set snapshot dimensions
|
|
62
|
+
--no-color plain-text snapshot output
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Data source
|
|
66
|
+
|
|
67
|
+
Touchline does not scrape rendered HTML. It discovers and consumes the public JSON requests made by the eNetScores widget. The client code, event id and language are parsed from the supplied URL; tournament and bracket ids are discovered from the match metadata. If the provider changes its private widget schema in the future, the parsers in `src/touchline/api.py` may need to be adjusted.
|
package/bin/touchline.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
|
|
6
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
7
|
+
const sourceRoot = path.join(packageRoot, "src");
|
|
8
|
+
|
|
9
|
+
function pythonCandidates() {
|
|
10
|
+
const configured = process.env.TOUCHLINE_PYTHON;
|
|
11
|
+
if (configured) return [[configured, []]];
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
return [["py", ["-3"]], ["python", []], ["python3", []]];
|
|
14
|
+
}
|
|
15
|
+
return [["python3", []], ["python", []]];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findPython() {
|
|
19
|
+
for (const [command, prefix] of pythonCandidates()) {
|
|
20
|
+
const probe = spawnSync(
|
|
21
|
+
command,
|
|
22
|
+
[...prefix, "-c", "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)"],
|
|
23
|
+
{ stdio: "ignore" },
|
|
24
|
+
);
|
|
25
|
+
if (probe.status === 0) return [command, prefix];
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const python = findPython();
|
|
31
|
+
if (!python) {
|
|
32
|
+
console.error("touchline needs Python 3.10 or newer, but no compatible Python was found.");
|
|
33
|
+
console.error("Install Python from https://python.org, or set TOUCHLINE_PYTHON to its path.");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [command, prefix] = python;
|
|
38
|
+
const existingPath = process.env.PYTHONPATH;
|
|
39
|
+
const env = {
|
|
40
|
+
...process.env,
|
|
41
|
+
PYTHONPATH: existingPath ? `${sourceRoot}${path.delimiter}${existingPath}` : sourceRoot,
|
|
42
|
+
};
|
|
43
|
+
const result = spawnSync(
|
|
44
|
+
command,
|
|
45
|
+
[...prefix, "-m", "touchline", ...process.argv.slice(2)],
|
|
46
|
+
{ stdio: "inherit", env },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (result.error) {
|
|
50
|
+
console.error(`touchline could not start: ${result.error.message}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (result.signal) {
|
|
54
|
+
process.kill(process.pid, result.signal);
|
|
55
|
+
}
|
|
56
|
+
process.exit(result.status ?? 1);
|
|
57
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "touchline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A live football match centre for your terminal",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Hamed",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"bin": {
|
|
9
|
+
"touchline": "bin/touchline.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src/touchline/*.py",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"football",
|
|
22
|
+
"soccer",
|
|
23
|
+
"live-score",
|
|
24
|
+
"terminal",
|
|
25
|
+
"tui",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .model import Event, MatchState, Team
|
|
12
|
+
|
|
13
|
+
DEFAULT_IFRAME_URL = (
|
|
14
|
+
"https://api.svt.se/news/embed/script-iframe?"
|
|
15
|
+
"url=https%3A%2F%2Fwidget.enetscores.com%2FFW15C12BED468AE5A7%2F"
|
|
16
|
+
"ev%2F4653858%2Flng%2Fsv"
|
|
17
|
+
)
|
|
18
|
+
API_VERSION = "11.546"
|
|
19
|
+
THEME = "theme_webchunk_22"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FeedError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_widget_url(value: str) -> tuple[str, str, str]:
|
|
27
|
+
"""Return client code, event id and language from an iframe/widget URL."""
|
|
28
|
+
parsed = urllib.parse.urlparse(value)
|
|
29
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
30
|
+
inner = query.get("url", [value])[0]
|
|
31
|
+
decoded = urllib.parse.unquote(inner)
|
|
32
|
+
match = re.search(r"widget\.enetscores\.com/([^/]+)/ev/(\d+)/lng/([a-z-]+)", decoded)
|
|
33
|
+
if not match:
|
|
34
|
+
match = re.search(r"widget\.enetscores\.com/([^/]+)/(?:ev/)?(\d+)/(?:lng/)?([a-z-]+)?", decoded)
|
|
35
|
+
if not match:
|
|
36
|
+
raise FeedError("Could not find the eNetScores client and event id in that URL")
|
|
37
|
+
return match.group(1), match.group(2), match.group(3) or "sv"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _decode_content(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
41
|
+
content = payload.get("content", [])
|
|
42
|
+
if isinstance(content, str):
|
|
43
|
+
decoded = json.loads(content)
|
|
44
|
+
return [{"data": decoded, "fields": []}]
|
|
45
|
+
if isinstance(content, dict):
|
|
46
|
+
return [{"data": content, "fields": []}]
|
|
47
|
+
return content if isinstance(content, list) else []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EnetClient:
|
|
51
|
+
def __init__(self, iframe_url: str = DEFAULT_IFRAME_URL, timeout: float = 8.0, language: str | None = "en"):
|
|
52
|
+
self.client_code, self.event_id, url_language = parse_widget_url(iframe_url)
|
|
53
|
+
self.language = language or url_language
|
|
54
|
+
if not re.fullmatch(r"[a-z]{2}(?:-[a-z]{2})?", self.language):
|
|
55
|
+
raise FeedError(f"Unsupported language code: {self.language}")
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
self.base = f"https://es-ds.enetscores.com/{API_VERSION}/{self.client_code}"
|
|
58
|
+
self.stage_id = ""
|
|
59
|
+
self.draw_id = ""
|
|
60
|
+
|
|
61
|
+
def _get(self, url: str) -> dict[str, Any]:
|
|
62
|
+
request = urllib.request.Request(
|
|
63
|
+
url,
|
|
64
|
+
headers={
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
"User-Agent": "Touchline/0.1 (+terminal match centre)",
|
|
67
|
+
"Origin": "https://api.svt.se",
|
|
68
|
+
"Referer": "https://api.svt.se/",
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
73
|
+
return json.load(response)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
raise FeedError(f"Live feed unavailable: {exc}") from exc
|
|
76
|
+
|
|
77
|
+
def _url(self, subject: str) -> str:
|
|
78
|
+
return f"{self.base}/{subject.lstrip('/')}"
|
|
79
|
+
|
|
80
|
+
def discover(self) -> dict[str, Any]:
|
|
81
|
+
subject = (
|
|
82
|
+
f"live-{self.language}-event_compound_top_component-1-{self.event_id}"
|
|
83
|
+
f"-t_g-{THEME}"
|
|
84
|
+
)
|
|
85
|
+
payload = self._get(self._url(subject))
|
|
86
|
+
blocks = _decode_content(payload)
|
|
87
|
+
data = blocks[0].get("data", {}) if blocks else {}
|
|
88
|
+
event = data.get("e", {})
|
|
89
|
+
self.stage_id = str(event.get("tsid", ""))
|
|
90
|
+
if self.stage_id:
|
|
91
|
+
full = (
|
|
92
|
+
f"live-{self.language}-event_compound-event_compound_top_component-1-{self.event_id}"
|
|
93
|
+
f"-t_g-{THEME}-event_compound-ms-standings-ts-{self.stage_id}"
|
|
94
|
+
f"-ms-draw-ts-{self.stage_id}"
|
|
95
|
+
)
|
|
96
|
+
full_blocks = _decode_content(self._get(self._url(full)))
|
|
97
|
+
if len(full_blocks) > 1:
|
|
98
|
+
self.draw_id = str(full_blocks[1].get("data", {}).get("draw", ""))
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
def subjects(self) -> dict[str, str]:
|
|
102
|
+
e = self.event_id
|
|
103
|
+
lang = self.language
|
|
104
|
+
common = f"live-{lang}-event_compound"
|
|
105
|
+
result = {
|
|
106
|
+
"core": f"live-{lang}-event_compound_top_component-1-{e}-t_g-{THEME}",
|
|
107
|
+
"match": (
|
|
108
|
+
f"/{common}-event-incidents-1-{e}-t_g-id_type_event_info-{THEME}"
|
|
109
|
+
f"-icf_all-otid_0-event_compound-action_map-1-{e}-p0300-t_g-{THEME}"
|
|
110
|
+
f"-event_compound-momentum-1-{e}-t_g-{THEME}"
|
|
111
|
+
),
|
|
112
|
+
"comments": f"/{common}-event-comments-1-{e}-t_g-cmd_type_comments-{THEME}",
|
|
113
|
+
"stats": f"{common}-event-livestats-1-{e}-t_g-lsd_type_event_stats-{THEME}-ls_dt_type_default",
|
|
114
|
+
"lineups": f"/{common}-event-lineups-1-{e}-t_g-{THEME}-ln_type_lineups-tm_tbs_yes",
|
|
115
|
+
"formation": f"/{common}-event-lineups-1-{e}-t_g-{THEME}-ln_type_formation_lineups",
|
|
116
|
+
"h2h": (
|
|
117
|
+
f"/{common}-event-h2h-1-{e}-t_g-h2hd_type_h2h-{THEME}"
|
|
118
|
+
"-otid_0-sel_tb_all-el_limit_5-ev_n_r_5-emb_yes"
|
|
119
|
+
),
|
|
120
|
+
"players": (
|
|
121
|
+
f"{common}-extended_participants_stats-1--par_t_event-t_g-{THEME}"
|
|
122
|
+
f"-stat_cat_general--ev_{e}-fss_0"
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
if self.draw_id:
|
|
126
|
+
result["bracket"] = f"/{common}-tournament_stage_draw-1-{self.draw_id}-t_g-{THEME}"
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def fetch(self, include_static: bool = True) -> dict[str, dict[str, Any]]:
|
|
130
|
+
if not self.stage_id:
|
|
131
|
+
core = self.discover()
|
|
132
|
+
else:
|
|
133
|
+
core = None
|
|
134
|
+
subjects = self.subjects()
|
|
135
|
+
dynamic = {"core", "match", "comments", "stats", "players"}
|
|
136
|
+
selected = subjects if include_static else {k: v for k, v in subjects.items() if k in dynamic}
|
|
137
|
+
result: dict[str, dict[str, Any]] = {}
|
|
138
|
+
if core is not None:
|
|
139
|
+
result["core"] = {"content": core}
|
|
140
|
+
selected.pop("core", None)
|
|
141
|
+
with ThreadPoolExecutor(max_workers=min(6, len(selected) or 1)) as pool:
|
|
142
|
+
futures = {pool.submit(self._get, self._url(subject)): name for name, subject in selected.items()}
|
|
143
|
+
for future in as_completed(futures):
|
|
144
|
+
name = futures[future]
|
|
145
|
+
result[name] = future.result()
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _event_data(payload: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
150
|
+
blocks = _decode_content(payload)
|
|
151
|
+
if not blocks:
|
|
152
|
+
return {}, []
|
|
153
|
+
return blocks[0].get("data", {}), blocks[0].get("fields", [])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _team(raw: dict[str, Any]) -> Team:
|
|
157
|
+
return Team(
|
|
158
|
+
id=str(raw.get("pi") or raw.get("ti") or ""),
|
|
159
|
+
name=str(raw.get("pn") or raw.get("tn") or "—"),
|
|
160
|
+
short=str(raw.get("pns") or raw.get("tns") or raw.get("pn") or "—"),
|
|
161
|
+
score=str(raw.get("frs") or raw.get("rs6") or raw.get("rs1") or "0"),
|
|
162
|
+
country=str(raw.get("cns") or raw.get("cn") or ""),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_core(state: MatchState, payload: dict[str, Any]) -> None:
|
|
167
|
+
if "content" in payload and isinstance(payload["content"], dict):
|
|
168
|
+
data = payload["content"]
|
|
169
|
+
else:
|
|
170
|
+
data, _ = _event_data(payload)
|
|
171
|
+
event = data.get("e", {})
|
|
172
|
+
participants = event.get("par", {})
|
|
173
|
+
if participants:
|
|
174
|
+
state.home = _team(participants.get("1", {}))
|
|
175
|
+
state.away = _team(participants.get("2", {}))
|
|
176
|
+
state.status = str(event.get("st", state.status))
|
|
177
|
+
state.period = str(event.get("sns") or event.get("sn") or state.period)
|
|
178
|
+
state.minute = str(event.get("et") or state.minute)
|
|
179
|
+
state.round_name = str(event.get("rnd") or state.round_name)
|
|
180
|
+
state.start_time = str(event.get("sd") or state.start_time)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_comments(state: MatchState, payload: dict[str, Any]) -> None:
|
|
184
|
+
data, fields = _event_data(payload)
|
|
185
|
+
tournament = data.get("t", {})
|
|
186
|
+
event = data.get("e", {})
|
|
187
|
+
venue = event.get("vn", {})
|
|
188
|
+
state.competition = str(tournament.get("nm") or tournament.get("nms") or state.competition)
|
|
189
|
+
state.venue = str(venue.get("nm") or venue.get("vnm") or state.venue)
|
|
190
|
+
state.city = str(venue.get("ct") or venue.get("cts") or state.city)
|
|
191
|
+
state.minute = str(event.get("et") or state.minute)
|
|
192
|
+
parsed: list[Event] = []
|
|
193
|
+
for field in fields:
|
|
194
|
+
if not str(field.get("name", "")).startswith("ei_"):
|
|
195
|
+
continue
|
|
196
|
+
try:
|
|
197
|
+
item = json.loads(field.get("value", "{}"))
|
|
198
|
+
except (TypeError, json.JSONDecodeError):
|
|
199
|
+
continue
|
|
200
|
+
parsed.append(Event(
|
|
201
|
+
minute=str(item.get("et", "")),
|
|
202
|
+
kind=str(item.get("tp", "info")),
|
|
203
|
+
text=str(item.get("nm", "")),
|
|
204
|
+
team_id=str(item.get("ti", "")),
|
|
205
|
+
sequence=int(item.get("s", 0) or 0),
|
|
206
|
+
))
|
|
207
|
+
state.events = sorted(parsed, key=lambda item: item.sequence, reverse=True)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_stats(state: MatchState, payload: dict[str, Any]) -> None:
|
|
211
|
+
data, _ = _event_data(payload)
|
|
212
|
+
stats = data.get("ls", {})
|
|
213
|
+
state.stats = {
|
|
214
|
+
key: {"home": str(value.get("1", "0")), "away": str(value.get("2", "0"))}
|
|
215
|
+
for key, value in stats.items()
|
|
216
|
+
if isinstance(value, dict) and not key.endswith("_541")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _parse_match(state: MatchState, payload: dict[str, Any]) -> None:
|
|
221
|
+
blocks = _decode_content(payload)
|
|
222
|
+
if not blocks:
|
|
223
|
+
return
|
|
224
|
+
info = blocks[0].get("data", {})
|
|
225
|
+
event = info.get("e", {})
|
|
226
|
+
venue = info.get("vn", {})
|
|
227
|
+
tournament = info.get("t", {})
|
|
228
|
+
state.round_name = str(event.get("rnd") or state.round_name)
|
|
229
|
+
state.venue = str(venue.get("nm") or state.venue)
|
|
230
|
+
state.city = str(venue.get("ct") or state.city)
|
|
231
|
+
state.competition = str(tournament.get("nm") or state.competition)
|
|
232
|
+
if len(blocks) > 1:
|
|
233
|
+
fields = {field.get("name"): field.get("value") for field in blocks[1].get("fields", [])}
|
|
234
|
+
try:
|
|
235
|
+
actions = json.loads(fields.get("a", "{}"))
|
|
236
|
+
except (TypeError, json.JSONDecodeError):
|
|
237
|
+
actions = {}
|
|
238
|
+
state.pitch_actions = [
|
|
239
|
+
action for action in actions.values()
|
|
240
|
+
if action.get("coords_set") and action.get("coords_x") not in (None, "")
|
|
241
|
+
]
|
|
242
|
+
if len(blocks) > 2:
|
|
243
|
+
momentum_data = blocks[2].get("data", {}).get("data", {})
|
|
244
|
+
momentum_event = momentum_data.get("e", {})
|
|
245
|
+
if momentum_event.get("et") not in (None, ""):
|
|
246
|
+
state.minute = str(momentum_event["et"])
|
|
247
|
+
incident_values = momentum_data.get("incidents", {}).values()
|
|
248
|
+
points: list[tuple[int, float]] = []
|
|
249
|
+
for item in incident_values:
|
|
250
|
+
incident = item.get("incident_data", {})
|
|
251
|
+
try:
|
|
252
|
+
minute = int(incident.get("elapsed", 0))
|
|
253
|
+
value = float(incident.get("home_pt", 0)) - float(incident.get("away_pt", 0))
|
|
254
|
+
except (TypeError, ValueError):
|
|
255
|
+
continue
|
|
256
|
+
points.append((minute, value))
|
|
257
|
+
if points:
|
|
258
|
+
try:
|
|
259
|
+
maximum = int(momentum_event.get("et", ""))
|
|
260
|
+
except (TypeError, ValueError):
|
|
261
|
+
maximum = max(minute for minute, _ in points)
|
|
262
|
+
state.momentum = [0.0] * (maximum + 1)
|
|
263
|
+
for minute, value in points:
|
|
264
|
+
if minute <= maximum:
|
|
265
|
+
state.momentum[minute] = value
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse_lineups(state: MatchState, payload: dict[str, Any]) -> None:
|
|
269
|
+
data, _ = _event_data(payload)
|
|
270
|
+
groups = data.get("lnp", {})
|
|
271
|
+
parsed: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
|
272
|
+
for key, group in groups.items():
|
|
273
|
+
listing = group.get("list", {}) if isinstance(group, dict) else {}
|
|
274
|
+
parsed[key] = {
|
|
275
|
+
"home": listing.get("home", []),
|
|
276
|
+
"away": listing.get("away", []),
|
|
277
|
+
}
|
|
278
|
+
state.lineups = parsed
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_formation(state: MatchState, payload: dict[str, Any]) -> None:
|
|
282
|
+
data, _ = _event_data(payload)
|
|
283
|
+
participants = data.get("e", {}).get("par", {})
|
|
284
|
+
state.formations = {
|
|
285
|
+
"home": str(participants.get("1", {}).get("formation", "")),
|
|
286
|
+
"away": str(participants.get("2", {}).get("formation", "")),
|
|
287
|
+
}
|
|
288
|
+
if not state.lineups:
|
|
289
|
+
_parse_lineups(state, payload)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _event_row(item: dict[str, Any]) -> dict[str, Any]:
|
|
293
|
+
par = item.get("par", {})
|
|
294
|
+
home, away = par.get("1", {}), par.get("2", {})
|
|
295
|
+
return {
|
|
296
|
+
"date": str(item.get("sd", ""))[:10],
|
|
297
|
+
"home": home.get("pn", "—"),
|
|
298
|
+
"away": away.get("pn", "—"),
|
|
299
|
+
"home_score": home.get("frs", home.get("rs6", "–")),
|
|
300
|
+
"away_score": away.get("frs", away.get("rs6", "–")),
|
|
301
|
+
"competition": item.get("ttn", ""),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _parse_h2h(state: MatchState, payload: dict[str, Any]) -> None:
|
|
306
|
+
data, _ = _event_data(payload)
|
|
307
|
+
sections = data.get("data", {})
|
|
308
|
+
state.h2h = {}
|
|
309
|
+
for name in ("h2h", "home", "away"):
|
|
310
|
+
events = sections.get(name, {}).get("e", {})
|
|
311
|
+
state.h2h[name] = [_event_row(item) for item in events.values()]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _parse_players(state: MatchState, payload: dict[str, Any]) -> None:
|
|
315
|
+
data, _ = _event_data(payload)
|
|
316
|
+
raw = data.get("data", [])
|
|
317
|
+
if isinstance(raw, dict):
|
|
318
|
+
raw = list(raw.values())
|
|
319
|
+
state.player_stats = raw if isinstance(raw, list) else []
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _parse_bracket(state: MatchState, payload: dict[str, Any]) -> None:
|
|
323
|
+
outer, _ = _event_data(payload)
|
|
324
|
+
draw = outer.get("data", {}).get("dr", {})
|
|
325
|
+
rounds = draw.get("dre", {})
|
|
326
|
+
parsed: dict[str, list[dict[str, Any]]] = {}
|
|
327
|
+
for round_name, round_data in rounds.items():
|
|
328
|
+
matches: list[dict[str, Any]] = []
|
|
329
|
+
for pair in round_data.get("pairs", {}).values():
|
|
330
|
+
for item in pair.get("events", {}).values():
|
|
331
|
+
matches.append(_event_row(item))
|
|
332
|
+
parsed[round_name] = matches
|
|
333
|
+
state.bracket = parsed
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
PARSERS = {
|
|
337
|
+
"core": _parse_core,
|
|
338
|
+
"match": _parse_match,
|
|
339
|
+
"comments": _parse_comments,
|
|
340
|
+
"stats": _parse_stats,
|
|
341
|
+
"lineups": _parse_lineups,
|
|
342
|
+
"formation": _parse_formation,
|
|
343
|
+
"h2h": _parse_h2h,
|
|
344
|
+
"players": _parse_players,
|
|
345
|
+
"bracket": _parse_bracket,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def merge_payloads(state: MatchState, payloads: dict[str, dict[str, Any]]) -> MatchState:
|
|
350
|
+
for name, payload in payloads.items():
|
|
351
|
+
parser = PARSERS.get(name)
|
|
352
|
+
if parser:
|
|
353
|
+
parser(state, payload)
|
|
354
|
+
state.updated_at = datetime.now()
|
|
355
|
+
state.loading = False
|
|
356
|
+
state.error = ""
|
|
357
|
+
return state
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import select
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import termios
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import tty
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from .api import DEFAULT_IFRAME_URL, EnetClient, FeedError, merge_payloads
|
|
16
|
+
from .demo import demo_state
|
|
17
|
+
from .model import MatchState
|
|
18
|
+
from .ui import Renderer, TABS, strip_ansi
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AppState:
|
|
23
|
+
tab: int = 0
|
|
24
|
+
scroll: int = 0
|
|
25
|
+
running: bool = True
|
|
26
|
+
force_refresh: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TouchlineApp:
|
|
30
|
+
def __init__(self, url: str, refresh: float = 10.0, demo: bool = False, language: str = "en"):
|
|
31
|
+
self.demo = demo
|
|
32
|
+
self.refresh_interval = max(5.0, refresh)
|
|
33
|
+
self.ui = AppState()
|
|
34
|
+
self.match = demo_state() if demo else MatchState(event_id="")
|
|
35
|
+
self.client = None if demo else EnetClient(url, language=language)
|
|
36
|
+
if self.client:
|
|
37
|
+
self.match.event_id = self.client.event_id
|
|
38
|
+
self.results: queue.Queue[tuple[dict | None, Exception | None]] = queue.Queue()
|
|
39
|
+
self.fetching = False
|
|
40
|
+
self.loaded_static = False
|
|
41
|
+
self.last_fetch = 0.0
|
|
42
|
+
|
|
43
|
+
def _fetch(self, include_static: bool) -> None:
|
|
44
|
+
try:
|
|
45
|
+
assert self.client is not None
|
|
46
|
+
self.results.put((self.client.fetch(include_static=include_static), None))
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
self.results.put((None, exc))
|
|
49
|
+
|
|
50
|
+
def request_refresh(self, force: bool = False) -> None:
|
|
51
|
+
if self.demo or self.fetching:
|
|
52
|
+
return
|
|
53
|
+
if not force and time.monotonic() - self.last_fetch < self.refresh_interval:
|
|
54
|
+
return
|
|
55
|
+
self.fetching = True
|
|
56
|
+
thread = threading.Thread(target=self._fetch, args=(not self.loaded_static,), daemon=True)
|
|
57
|
+
thread.start()
|
|
58
|
+
|
|
59
|
+
def collect(self) -> bool:
|
|
60
|
+
changed = False
|
|
61
|
+
while True:
|
|
62
|
+
try:
|
|
63
|
+
payloads, error = self.results.get_nowait()
|
|
64
|
+
except queue.Empty:
|
|
65
|
+
break
|
|
66
|
+
self.fetching = False
|
|
67
|
+
self.last_fetch = time.monotonic()
|
|
68
|
+
if error:
|
|
69
|
+
self.match.error = str(error).replace("Live feed unavailable: ", "")
|
|
70
|
+
self.match.loading = False
|
|
71
|
+
elif payloads is not None:
|
|
72
|
+
merge_payloads(self.match, payloads)
|
|
73
|
+
self.loaded_static = True
|
|
74
|
+
changed = True
|
|
75
|
+
return changed
|
|
76
|
+
|
|
77
|
+
def frame(self, width: int, height: int) -> str:
|
|
78
|
+
return Renderer(self.match, self.ui.tab, self.ui.scroll, self.demo).render(width, height)
|
|
79
|
+
|
|
80
|
+
def handle_key(self, key: str) -> None:
|
|
81
|
+
if key in ("q", "Q", "\x03"):
|
|
82
|
+
self.ui.running = False
|
|
83
|
+
elif key in ("\x1b[C", "l", "L", "\t"):
|
|
84
|
+
self.ui.tab = (self.ui.tab + 1) % len(TABS)
|
|
85
|
+
self.ui.scroll = 0
|
|
86
|
+
elif key in ("\x1b[D", "h", "H", "\x1b[Z"):
|
|
87
|
+
self.ui.tab = (self.ui.tab - 1) % len(TABS)
|
|
88
|
+
self.ui.scroll = 0
|
|
89
|
+
elif key in ("j", "J", "\x1b[B"):
|
|
90
|
+
self.ui.scroll += 1
|
|
91
|
+
elif key in ("k", "K", "\x1b[A"):
|
|
92
|
+
self.ui.scroll = max(0, self.ui.scroll - 1)
|
|
93
|
+
elif key in ("r", "R"):
|
|
94
|
+
self.last_fetch = 0
|
|
95
|
+
self.request_refresh(force=True)
|
|
96
|
+
elif key.isdigit() and 1 <= int(key) <= len(TABS):
|
|
97
|
+
self.ui.tab = int(key) - 1
|
|
98
|
+
self.ui.scroll = 0
|
|
99
|
+
|
|
100
|
+
def run(self) -> None:
|
|
101
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
102
|
+
raise SystemExit("Interactive mode needs a TTY. Try --snapshot for a static frame.")
|
|
103
|
+
previous = termios.tcgetattr(sys.stdin)
|
|
104
|
+
try:
|
|
105
|
+
tty.setcbreak(sys.stdin.fileno())
|
|
106
|
+
sys.stdout.write("\x1b[?1049h\x1b[?25l")
|
|
107
|
+
self.request_refresh(force=True)
|
|
108
|
+
dirty = True
|
|
109
|
+
while self.ui.running:
|
|
110
|
+
self.request_refresh()
|
|
111
|
+
dirty = self.collect() or dirty
|
|
112
|
+
width, height = shutil.get_terminal_size((120, 36))
|
|
113
|
+
if dirty:
|
|
114
|
+
sys.stdout.write("\x1b[H" + self.frame(width, height))
|
|
115
|
+
sys.stdout.flush()
|
|
116
|
+
dirty = False
|
|
117
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.15)
|
|
118
|
+
if ready:
|
|
119
|
+
key = os.read(sys.stdin.fileno(), 8).decode(errors="ignore")
|
|
120
|
+
self.handle_key(key)
|
|
121
|
+
dirty = True
|
|
122
|
+
finally:
|
|
123
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, previous)
|
|
124
|
+
sys.stdout.write("\x1b[?25h\x1b[?1049l")
|
|
125
|
+
sys.stdout.flush()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
129
|
+
parser = argparse.ArgumentParser(description="Follow a live football match from your terminal")
|
|
130
|
+
parser.add_argument("url", nargs="?", default=DEFAULT_IFRAME_URL, help="SVT iframe or eNetScores widget URL")
|
|
131
|
+
parser.add_argument("--refresh", type=float, default=10.0, metavar="SECONDS", help="refresh interval (minimum 5)")
|
|
132
|
+
parser.add_argument("--language", "-L", default="en", help="commentary language (default: en; try sv, es, de)")
|
|
133
|
+
parser.add_argument("--demo", action="store_true", help="use polished sample data without a network connection")
|
|
134
|
+
parser.add_argument("--snapshot", action="store_true", help="print one frame and exit")
|
|
135
|
+
parser.add_argument("--width", type=int, default=120, help="snapshot width")
|
|
136
|
+
parser.add_argument("--height", type=int, default=36, help="snapshot height")
|
|
137
|
+
parser.add_argument("--tab", type=int, choices=range(1, 9), default=1, help="initial view, 1–8")
|
|
138
|
+
parser.add_argument("--no-color", action="store_true", help="strip ANSI color from snapshots")
|
|
139
|
+
return parser
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
args = build_parser().parse_args()
|
|
144
|
+
try:
|
|
145
|
+
app = TouchlineApp(args.url, args.refresh, args.demo, args.language)
|
|
146
|
+
except FeedError as exc:
|
|
147
|
+
raise SystemExit(f"touchline: {exc}") from exc
|
|
148
|
+
app.ui.tab = args.tab - 1
|
|
149
|
+
if args.snapshot:
|
|
150
|
+
if not args.demo:
|
|
151
|
+
try:
|
|
152
|
+
assert app.client is not None
|
|
153
|
+
merge_payloads(app.match, app.client.fetch(include_static=True))
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
app.match.loading = False
|
|
156
|
+
app.match.error = str(exc)
|
|
157
|
+
frame = app.frame(args.width, args.height)
|
|
158
|
+
print(strip_ansi(frame) if args.no_color else frame, end="")
|
|
159
|
+
return
|
|
160
|
+
app.run()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from .model import Event, MatchState, Team
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def demo_state() -> MatchState:
|
|
9
|
+
state = MatchState(event_id="4653858")
|
|
10
|
+
state.home = Team("6720", "Spain", "ESP", "2", "ESP")
|
|
11
|
+
state.away = Team("6706", "Argentina", "ARG", "1", "ARG")
|
|
12
|
+
state.status = "inprogress"
|
|
13
|
+
state.period = "2nd half"
|
|
14
|
+
state.minute = "67"
|
|
15
|
+
state.competition = "World Cup 2026"
|
|
16
|
+
state.round_name = "Final"
|
|
17
|
+
state.venue = "New York/New Jersey Stadium"
|
|
18
|
+
state.city = "East Rutherford"
|
|
19
|
+
state.events = [
|
|
20
|
+
Event("67", "shoton", "Lamine Yamal bends a shot toward the far corner. Martinez saves.", "6720", 14),
|
|
21
|
+
Event("63", "subst", "Argentina: Lautaro Martinez replaces Nicolas Gonzalez.", "6706", 13),
|
|
22
|
+
Event("58", "goal", "GOAL Spain — Dani Olmo finds the bottom-left corner.", "6720", 12),
|
|
23
|
+
Event("53", "yellowcard", "Cristian Romero is booked for a late challenge.", "6706", 11),
|
|
24
|
+
Event("46", "2h", "The second half is underway.", "", 10),
|
|
25
|
+
Event("41", "goal", "GOAL Argentina — Lionel Messi converts from the spot.", "6706", 9),
|
|
26
|
+
Event("24", "goal", "GOAL Spain — Mikel Oyarzabal heads home.", "6720", 8),
|
|
27
|
+
]
|
|
28
|
+
state.stats = {
|
|
29
|
+
"expected_goal": {"home": "1.82", "away": "1.10"},
|
|
30
|
+
"possession": {"home": "57", "away": "43"},
|
|
31
|
+
"goal_attempt": {"home": "13", "away": "8"},
|
|
32
|
+
"shoton": {"home": "6", "away": "3"},
|
|
33
|
+
"shotoff": {"home": "4", "away": "3"},
|
|
34
|
+
"corner": {"home": "5", "away": "2"},
|
|
35
|
+
"foulcommit": {"home": "8", "away": "11"},
|
|
36
|
+
"offside": {"home": "1", "away": "2"},
|
|
37
|
+
"yellow_cards": {"home": "1", "away": "2"},
|
|
38
|
+
"red_cards": {"home": "0", "away": "0"},
|
|
39
|
+
}
|
|
40
|
+
state.momentum = [0, 1, -1, 0, 2, 3, 1, -2, -1, 2, 4, 2, 1, -3, -2, 1, 3, 2, 4, 1]
|
|
41
|
+
state.pitch_actions = [
|
|
42
|
+
{"coords_x": "22", "coords_y": "35", "ti": "6720", "tp": "shoton"},
|
|
43
|
+
{"coords_x": "71", "coords_y": "64", "ti": "6706", "tp": "foulcommit"},
|
|
44
|
+
{"coords_x": "84", "coords_y": "48", "ti": "6720", "tp": "goal"},
|
|
45
|
+
{"coords_x": "44", "coords_y": "21", "ti": "6706", "tp": "cross"},
|
|
46
|
+
{"coords_x": "66", "coords_y": "38", "ti": "6720", "tp": "shoton"},
|
|
47
|
+
]
|
|
48
|
+
home = [
|
|
49
|
+
("23", "Unai Simon", 0), ("12", "Pedro Porro", 1), ("22", "Pau Cubarsi", 1),
|
|
50
|
+
("14", "Aymeric Laporte", 1), ("24", "Marc Cucurella", 1), ("16", "Rodri", 2),
|
|
51
|
+
("8", "Fabian Ruiz", 2), ("19", "Lamine Yamal", 2), ("10", "Dani Olmo", 2),
|
|
52
|
+
("15", "Alex Baena", 2), ("21", "Mikel Oyarzabal", 3),
|
|
53
|
+
]
|
|
54
|
+
away = [
|
|
55
|
+
("23", "Emiliano Martinez", 0), ("4", "Gonzalo Montiel", 1), ("13", "Cristian Romero", 1),
|
|
56
|
+
("6", "Lisandro Martinez", 1), ("3", "Nicolas Tagliafico", 1), ("7", "Rodrigo De Paul", 2),
|
|
57
|
+
("24", "Enzo Fernandez", 2), ("20", "Alexis Mac Allister", 2), ("15", "Nicolas Gonzalez", 2),
|
|
58
|
+
("10", "Lionel Messi", 3), ("9", "Julian Alvarez", 3),
|
|
59
|
+
]
|
|
60
|
+
convert = lambda rows: [{"sn": n, "pn": name, "pns": name, "so": pos} for n, name, pos in rows]
|
|
61
|
+
state.lineups = {"lineups": {"home": convert(home), "away": convert(away)}, "bench": {"home": [], "away": []}}
|
|
62
|
+
state.formations = {"home": "4-2-3-1", "away": "4-4-2"}
|
|
63
|
+
state.h2h = {"h2h": [
|
|
64
|
+
{"date": "2018-03-27", "home": "Spain", "away": "Argentina", "home_score": "6", "away_score": "1", "competition": "Friendly"},
|
|
65
|
+
{"date": "2010-09-07", "home": "Argentina", "away": "Spain", "home_score": "4", "away_score": "1", "competition": "Friendly"},
|
|
66
|
+
{"date": "2009-11-14", "home": "Spain", "away": "Argentina", "home_score": "2", "away_score": "1", "competition": "Friendly"},
|
|
67
|
+
], "home": [], "away": []}
|
|
68
|
+
state.bracket = {
|
|
69
|
+
"Quarter Finals": [
|
|
70
|
+
{"home": "France", "away": "Morocco", "home_score": "2", "away_score": "0", "date": "2026-07-09"},
|
|
71
|
+
{"home": "Spain", "away": "Belgium", "home_score": "2", "away_score": "1", "date": "2026-07-10"},
|
|
72
|
+
{"home": "Norway", "away": "England", "home_score": "1", "away_score": "2", "date": "2026-07-11"},
|
|
73
|
+
{"home": "Argentina", "away": "Switzerland", "home_score": "3", "away_score": "1", "date": "2026-07-12"},
|
|
74
|
+
],
|
|
75
|
+
"Semi Finals": [
|
|
76
|
+
{"home": "France", "away": "Spain", "home_score": "0", "away_score": "2", "date": "2026-07-14"},
|
|
77
|
+
{"home": "England", "away": "Argentina", "home_score": "1", "away_score": "2", "date": "2026-07-15"},
|
|
78
|
+
],
|
|
79
|
+
"Final": [{"home": "Spain", "away": "Argentina", "home_score": "2", "away_score": "1", "date": "LIVE"}],
|
|
80
|
+
}
|
|
81
|
+
state.updated_at = datetime.now()
|
|
82
|
+
state.loading = False
|
|
83
|
+
return state
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Team:
|
|
10
|
+
id: str = ""
|
|
11
|
+
name: str = "—"
|
|
12
|
+
short: str = "—"
|
|
13
|
+
score: str = "0"
|
|
14
|
+
country: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Event:
|
|
19
|
+
minute: str
|
|
20
|
+
kind: str
|
|
21
|
+
text: str
|
|
22
|
+
team_id: str = ""
|
|
23
|
+
sequence: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class MatchState:
|
|
28
|
+
event_id: str
|
|
29
|
+
home: Team = field(default_factory=Team)
|
|
30
|
+
away: Team = field(default_factory=Team)
|
|
31
|
+
status: str = "loading"
|
|
32
|
+
period: str = ""
|
|
33
|
+
minute: str = "—"
|
|
34
|
+
competition: str = "World Cup"
|
|
35
|
+
round_name: str = "Final"
|
|
36
|
+
venue: str = ""
|
|
37
|
+
city: str = ""
|
|
38
|
+
start_time: str = ""
|
|
39
|
+
events: list[Event] = field(default_factory=list)
|
|
40
|
+
stats: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
41
|
+
lineups: dict[str, dict[str, list[dict[str, Any]]]] = field(default_factory=dict)
|
|
42
|
+
formations: dict[str, str] = field(default_factory=dict)
|
|
43
|
+
player_stats: list[dict[str, Any]] = field(default_factory=list)
|
|
44
|
+
h2h: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
|
45
|
+
bracket: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
|
46
|
+
pitch_actions: list[dict[str, Any]] = field(default_factory=list)
|
|
47
|
+
momentum: list[float] = field(default_factory=list)
|
|
48
|
+
updated_at: datetime | None = None
|
|
49
|
+
error: str = ""
|
|
50
|
+
loading: bool = True
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_live(self) -> bool:
|
|
54
|
+
return self.status == "inprogress"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
STAT_LABELS = {
|
|
58
|
+
"expected_goal": "Expected goals",
|
|
59
|
+
"possession": "Possession",
|
|
60
|
+
"shoton": "Shots on target",
|
|
61
|
+
"shotoff": "Shots off target",
|
|
62
|
+
"goal_attempt": "Goal attempts",
|
|
63
|
+
"blocked_shots": "Blocked shots",
|
|
64
|
+
"corner": "Corners",
|
|
65
|
+
"cross": "Crosses",
|
|
66
|
+
"throwin": "Throw-ins",
|
|
67
|
+
"offside": "Offsides",
|
|
68
|
+
"goalkick": "Goal kicks",
|
|
69
|
+
"freekick": "Free kicks",
|
|
70
|
+
"foulcommit": "Fouls",
|
|
71
|
+
"yellow_cards": "Yellow cards",
|
|
72
|
+
"red_cards": "Red cards",
|
|
73
|
+
"saves": "Saves",
|
|
74
|
+
"treatment": "Treatments",
|
|
75
|
+
"subst": "Substitutions",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
EVENT_ICONS = {
|
|
79
|
+
"goal": "GOAL",
|
|
80
|
+
"yellowcard": "CARD",
|
|
81
|
+
"redcard": "RED",
|
|
82
|
+
"subst": "SUB",
|
|
83
|
+
"kickoff": "KICK",
|
|
84
|
+
"corner": "CORNER",
|
|
85
|
+
"shoton": "SHOT",
|
|
86
|
+
"shotoff": "MISS",
|
|
87
|
+
"foulcommit": "FOUL",
|
|
88
|
+
"offside": "OFF",
|
|
89
|
+
"fieldpossession": "PLAY",
|
|
90
|
+
"special": "INFO",
|
|
91
|
+
"1h": "START",
|
|
92
|
+
"2h": "START",
|
|
93
|
+
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from .model import EVENT_ICONS, STAT_LABELS, MatchState
|
|
8
|
+
|
|
9
|
+
BG = "#0a0f0d"
|
|
10
|
+
PANEL = "#101814"
|
|
11
|
+
PANEL_2 = "#15211b"
|
|
12
|
+
LINE = "#294036"
|
|
13
|
+
TEXT = "#eef5ef"
|
|
14
|
+
MUTED = "#82958b"
|
|
15
|
+
GREEN = "#54e08b"
|
|
16
|
+
LIME = "#c8f560"
|
|
17
|
+
AMBER = "#f4c95d"
|
|
18
|
+
RED = "#ff6b6b"
|
|
19
|
+
BLUE = "#68a8ff"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rgb(value: str) -> tuple[int, int, int]:
|
|
23
|
+
value = value.lstrip("#")
|
|
24
|
+
return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Style:
|
|
29
|
+
fg: str = TEXT
|
|
30
|
+
bg: str = BG
|
|
31
|
+
bold: bool = False
|
|
32
|
+
dim: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Cell:
|
|
37
|
+
char: str = " "
|
|
38
|
+
style: Style = Style()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Canvas:
|
|
42
|
+
def __init__(self, width: int, height: int):
|
|
43
|
+
self.width = max(40, width)
|
|
44
|
+
self.height = max(16, height)
|
|
45
|
+
self.rows = [[Cell() for _ in range(self.width)] for _ in range(self.height)]
|
|
46
|
+
|
|
47
|
+
def put(self, x: int, y: int, text: object, style: Style = Style(), max_width: int | None = None) -> None:
|
|
48
|
+
if not 0 <= y < self.height:
|
|
49
|
+
return
|
|
50
|
+
value = str(text).replace("\n", " ")
|
|
51
|
+
room = self.width - x if max_width is None else min(max_width, self.width - x)
|
|
52
|
+
if room <= 0:
|
|
53
|
+
return
|
|
54
|
+
if len(value) > room:
|
|
55
|
+
value = value[:max(0, room - 1)] + ("…" if room else "")
|
|
56
|
+
for offset, char in enumerate(value):
|
|
57
|
+
px = x + offset
|
|
58
|
+
if 0 <= px < self.width:
|
|
59
|
+
self.rows[y][px] = Cell(char, style)
|
|
60
|
+
|
|
61
|
+
def fill(self, x: int, y: int, width: int, height: int, style: Style, char: str = " ") -> None:
|
|
62
|
+
for py in range(max(0, y), min(self.height, y + height)):
|
|
63
|
+
for px in range(max(0, x), min(self.width, x + width)):
|
|
64
|
+
self.rows[py][px] = Cell(char, style)
|
|
65
|
+
|
|
66
|
+
def box(self, x: int, y: int, width: int, height: int, title: str = "") -> None:
|
|
67
|
+
if width < 3 or height < 2:
|
|
68
|
+
return
|
|
69
|
+
border = Style(LINE, PANEL)
|
|
70
|
+
self.fill(x, y, width, height, Style(TEXT, PANEL))
|
|
71
|
+
self.put(x, y, "┌" + "─" * (width - 2) + "┐", border)
|
|
72
|
+
self.put(x, y + height - 1, "└" + "─" * (width - 2) + "┘", border)
|
|
73
|
+
for row in range(y + 1, y + height - 1):
|
|
74
|
+
self.put(x, row, "│", border)
|
|
75
|
+
self.put(x + width - 1, row, "│", border)
|
|
76
|
+
if title:
|
|
77
|
+
label = f" {title.upper()} "
|
|
78
|
+
self.put(x + 2, y, label, Style(LIME, PANEL, bold=True), width - 4)
|
|
79
|
+
|
|
80
|
+
def render(self) -> str:
|
|
81
|
+
output: list[str] = []
|
|
82
|
+
previous: Style | None = None
|
|
83
|
+
for row in self.rows:
|
|
84
|
+
for cell in row:
|
|
85
|
+
if cell.style != previous:
|
|
86
|
+
fg = _rgb(cell.style.fg)
|
|
87
|
+
bg = _rgb(cell.style.bg)
|
|
88
|
+
attrs = [f"38;2;{fg[0]};{fg[1]};{fg[2]}", f"48;2;{bg[0]};{bg[1]};{bg[2]}"]
|
|
89
|
+
if cell.style.bold:
|
|
90
|
+
attrs.append("1")
|
|
91
|
+
if cell.style.dim:
|
|
92
|
+
attrs.append("2")
|
|
93
|
+
output.append("\x1b[0;" + ";".join(attrs) + "m")
|
|
94
|
+
previous = cell.style
|
|
95
|
+
output.append(cell.char)
|
|
96
|
+
output.append("\x1b[0m\n")
|
|
97
|
+
previous = None
|
|
98
|
+
return "".join(output)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
TABS = ["Overview", "Live", "Stats", "Lineups", "Formation", "Players", "H2H", "Bracket"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Renderer:
|
|
105
|
+
def __init__(self, state: MatchState, tab: int = 0, scroll: int = 0, demo: bool = False):
|
|
106
|
+
self.state = state
|
|
107
|
+
self.tab = tab
|
|
108
|
+
self.scroll = max(0, scroll)
|
|
109
|
+
self.demo = demo
|
|
110
|
+
|
|
111
|
+
def render(self, width: int, height: int) -> str:
|
|
112
|
+
c = Canvas(width, height)
|
|
113
|
+
self._header(c)
|
|
114
|
+
self._tabs(c)
|
|
115
|
+
content_y = 9
|
|
116
|
+
content_h = c.height - content_y - 2
|
|
117
|
+
renderers: list[Callable[[Canvas, int, int], None]] = [
|
|
118
|
+
self._overview, self._live, self._stats, self._lineups,
|
|
119
|
+
self._formation, self._players, self._h2h, self._bracket,
|
|
120
|
+
]
|
|
121
|
+
renderers[self.tab](c, content_y, content_h)
|
|
122
|
+
self._footer(c)
|
|
123
|
+
return c.render()
|
|
124
|
+
|
|
125
|
+
def _header(self, c: Canvas) -> None:
|
|
126
|
+
c.fill(0, 0, c.width, 7, Style(TEXT, PANEL_2))
|
|
127
|
+
live_color = RED if self.state.is_live else MUTED
|
|
128
|
+
c.put(2, 1, "TOUCHLINE", Style(LIME, PANEL_2, bold=True))
|
|
129
|
+
c.put(13, 1, "●", Style(live_color, PANEL_2, bold=True))
|
|
130
|
+
c.put(15, 1, "LIVE" if self.state.is_live else self.state.status.upper(), Style(live_color, PANEL_2, bold=True))
|
|
131
|
+
descriptor = f"{self.state.competition} / {self.state.round_name}"
|
|
132
|
+
c.put(2, 3, descriptor, Style(MUTED, PANEL_2), max(0, c.width // 3 - 2))
|
|
133
|
+
|
|
134
|
+
score = f"{self.state.home.score} : {self.state.away.score}"
|
|
135
|
+
centre = c.width // 2
|
|
136
|
+
c.put(max(2, centre - len(score) // 2), 2, score, Style(TEXT, PANEL_2, bold=True))
|
|
137
|
+
home = f"{self.state.home.short.upper()} {self.state.home.name}"
|
|
138
|
+
away = f"{self.state.away.name} {self.state.away.short.upper()}"
|
|
139
|
+
c.put(max(2, centre - 8 - len(home)), 2, home, Style(GREEN, PANEL_2, bold=True))
|
|
140
|
+
c.put(centre + 9, 2, away, Style(BLUE, PANEL_2, bold=True), max(0, c.width - centre - 11))
|
|
141
|
+
phase = f"{self.state.minute}' {self.state.period}" if self.state.is_live else self.state.period
|
|
142
|
+
c.put(max(2, centre - len(phase) // 2), 4, phase, Style(AMBER, PANEL_2, bold=True))
|
|
143
|
+
location = " · ".join(part for part in (self.state.venue, self.state.city) if part)
|
|
144
|
+
c.put(max(2, centre - len(location) // 2), 5, location, Style(MUTED, PANEL_2), c.width - 4)
|
|
145
|
+
|
|
146
|
+
updated = self.state.updated_at.strftime("%H:%M:%S") if self.state.updated_at else "waiting"
|
|
147
|
+
status = f"SYNC {updated}"
|
|
148
|
+
if self.demo:
|
|
149
|
+
status = "DEMO DATA"
|
|
150
|
+
c.put(max(2, c.width - len(status) - 2), 1, status, Style(MUTED, PANEL_2))
|
|
151
|
+
c.put(0, 6, "─" * c.width, Style(LINE, PANEL_2))
|
|
152
|
+
|
|
153
|
+
def _tabs(self, c: Canvas) -> None:
|
|
154
|
+
c.fill(0, 7, c.width, 2, Style(MUTED, BG))
|
|
155
|
+
x = 2
|
|
156
|
+
compact = c.width < 100
|
|
157
|
+
for index, name in enumerate(TABS):
|
|
158
|
+
label = f" {index + 1} {name if not compact else name[:4]} "
|
|
159
|
+
if x + len(label) >= c.width - 1:
|
|
160
|
+
break
|
|
161
|
+
selected = index == self.tab
|
|
162
|
+
c.put(x, 7, label, Style(BG if selected else MUTED, LIME if selected else BG, bold=selected))
|
|
163
|
+
x += len(label) + 1
|
|
164
|
+
|
|
165
|
+
def _footer(self, c: Canvas) -> None:
|
|
166
|
+
y = c.height - 1
|
|
167
|
+
c.fill(0, y, c.width, 1, Style(MUTED, PANEL_2))
|
|
168
|
+
left = " ←/→ views j/k scroll r refresh q quit"
|
|
169
|
+
c.put(0, y, left, Style(MUTED, PANEL_2))
|
|
170
|
+
if self.state.error:
|
|
171
|
+
c.put(max(1, c.width - len(self.state.error) - 2), y, self.state.error, Style(RED, PANEL_2), c.width // 2)
|
|
172
|
+
|
|
173
|
+
def _overview(self, c: Canvas, y: int, h: int) -> None:
|
|
174
|
+
if c.width < 88:
|
|
175
|
+
self._event_panel(c, 1, y, c.width - 2, h)
|
|
176
|
+
return
|
|
177
|
+
left = int(c.width * 0.61)
|
|
178
|
+
self._event_panel(c, 1, y, left - 2, h)
|
|
179
|
+
self._snapshot_panel(c, left, y, c.width - left - 1, h)
|
|
180
|
+
|
|
181
|
+
def _event_panel(self, c: Canvas, x: int, y: int, w: int, h: int) -> None:
|
|
182
|
+
c.box(x, y, w, h, "Latest from the pitch")
|
|
183
|
+
events = self.state.events[self.scroll:]
|
|
184
|
+
row = y + 2
|
|
185
|
+
for event in events:
|
|
186
|
+
if row >= y + h - 1:
|
|
187
|
+
break
|
|
188
|
+
icon = EVENT_ICONS.get(event.kind, event.kind[:5].upper())
|
|
189
|
+
accent = RED if event.kind == "goal" else AMBER if "card" in event.kind else GREEN
|
|
190
|
+
c.put(x + 2, row, f"{event.minute:>3}'", Style(MUTED, PANEL))
|
|
191
|
+
c.put(x + 8, row, f"{icon:<6}", Style(accent, PANEL, bold=True))
|
|
192
|
+
c.put(x + 15, row, event.text, Style(TEXT, PANEL), w - 17)
|
|
193
|
+
row += 2 if w < 70 else 1
|
|
194
|
+
if not events:
|
|
195
|
+
self._empty(c, x, y, w, h, "Waiting for the first match event…")
|
|
196
|
+
|
|
197
|
+
def _snapshot_panel(self, c: Canvas, x: int, y: int, w: int, h: int) -> None:
|
|
198
|
+
c.box(x, y, w, h, "Match pulse")
|
|
199
|
+
keys = ["possession", "expected_goal", "goal_attempt", "shoton"]
|
|
200
|
+
row = y + 2
|
|
201
|
+
for key in keys:
|
|
202
|
+
if row >= y + h - 2:
|
|
203
|
+
break
|
|
204
|
+
value = self.state.stats.get(key)
|
|
205
|
+
if not value:
|
|
206
|
+
continue
|
|
207
|
+
self._stat_row(c, x + 2, row, w - 4, STAT_LABELS.get(key, key), value)
|
|
208
|
+
row += 2
|
|
209
|
+
if self.state.momentum and row + 4 < y + h:
|
|
210
|
+
c.put(x + 2, row + 1, "MOMENTUM", Style(MUTED, PANEL, bold=True))
|
|
211
|
+
self._momentum(c, x + 2, row + 3, w - 4)
|
|
212
|
+
elif row == y + 2:
|
|
213
|
+
self._empty(c, x, y, w, h, "Stats will appear as play develops")
|
|
214
|
+
|
|
215
|
+
def _live(self, c: Canvas, y: int, h: int) -> None:
|
|
216
|
+
if c.width < 100:
|
|
217
|
+
self._event_panel(c, 1, y, c.width - 2, h)
|
|
218
|
+
return
|
|
219
|
+
left = int(c.width * 0.68)
|
|
220
|
+
self._event_panel(c, 1, y, left - 2, h)
|
|
221
|
+
self._pitch_map(c, left, y, c.width - left - 1, h)
|
|
222
|
+
|
|
223
|
+
def _momentum(self, c: Canvas, x: int, y: int, w: int, show_labels: bool = True) -> None:
|
|
224
|
+
points = self.state.momentum[-w:]
|
|
225
|
+
if not points:
|
|
226
|
+
return
|
|
227
|
+
maximum = max(max(abs(point) for point in points), 1)
|
|
228
|
+
glyphs = "▁▂▃▄▅▆▇█"
|
|
229
|
+
for index, point in enumerate(points):
|
|
230
|
+
level = min(7, int(abs(point) / maximum * 7))
|
|
231
|
+
color = GREEN if point >= 0 else BLUE
|
|
232
|
+
c.put(x + index, y, glyphs[level], Style(color, PANEL, bold=abs(point) == maximum))
|
|
233
|
+
if show_labels:
|
|
234
|
+
c.put(x, y + 1, self.state.home.short, Style(GREEN, PANEL), min(8, w))
|
|
235
|
+
away = self.state.away.short
|
|
236
|
+
c.put(x + max(0, w - len(away)), y + 1, away, Style(BLUE, PANEL))
|
|
237
|
+
|
|
238
|
+
def _pitch_map(self, c: Canvas, x: int, y: int, w: int, h: int) -> None:
|
|
239
|
+
c.box(x, y, w, h, "Action map")
|
|
240
|
+
px, py = x + 2, y + 2
|
|
241
|
+
pw, ph = w - 4, max(5, h - 8)
|
|
242
|
+
c.fill(px, py, pw, ph, Style(TEXT, "#123323"))
|
|
243
|
+
c.put(px, py, "┌" + "─" * max(0, pw - 2) + "┐", Style("#3b7657", "#123323"))
|
|
244
|
+
c.put(px, py + ph - 1, "└" + "─" * max(0, pw - 2) + "┘", Style("#3b7657", "#123323"))
|
|
245
|
+
for row in range(py + 1, py + ph - 1):
|
|
246
|
+
c.put(px, row, "│", Style("#3b7657", "#123323"))
|
|
247
|
+
c.put(px + pw - 1, row, "│", Style("#3b7657", "#123323"))
|
|
248
|
+
c.put(px + pw // 2, row, "│", Style("#3b7657", "#123323"))
|
|
249
|
+
c.put(px + pw // 2, py + ph // 2, "○", Style("#3b7657", "#123323"))
|
|
250
|
+
actions = self.state.pitch_actions[-40:]
|
|
251
|
+
for index, action in enumerate(actions):
|
|
252
|
+
try:
|
|
253
|
+
ax = float(action.get("coords_x", 0)) / 100
|
|
254
|
+
ay = float(action.get("coords_y", 0)) / 100
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
continue
|
|
257
|
+
plot_x = px + 1 + int(ax * max(1, pw - 3))
|
|
258
|
+
plot_y = py + 1 + int(ay * max(1, ph - 3))
|
|
259
|
+
home = str(action.get("ti", "")) == self.state.home.id
|
|
260
|
+
color = GREEN if home else BLUE
|
|
261
|
+
marker = "●" if index == len(actions) - 1 else "·"
|
|
262
|
+
c.put(plot_x, plot_y, marker, Style(color, "#123323", bold=marker == "●"))
|
|
263
|
+
legend_y = y + h - 4
|
|
264
|
+
c.put(x + 2, legend_y, f"● {self.state.home.short}", Style(GREEN, PANEL, bold=True))
|
|
265
|
+
away = f"● {self.state.away.short}"
|
|
266
|
+
c.put(x + max(2, w - len(away) - 2), legend_y, away, Style(BLUE, PANEL, bold=True))
|
|
267
|
+
if self.state.momentum:
|
|
268
|
+
c.put(x + 2, legend_y + 1, "MOMENTUM", Style(MUTED, PANEL, bold=True))
|
|
269
|
+
self._momentum(c, x + 2, legend_y + 2, w - 4, show_labels=False)
|
|
270
|
+
|
|
271
|
+
def _stat_row(self, c: Canvas, x: int, y: int, w: int, label: str, values: dict[str, str]) -> None:
|
|
272
|
+
home = values.get("home", "0")
|
|
273
|
+
away = values.get("away", "0")
|
|
274
|
+
c.put(x, y, f"{home:>5}", Style(GREEN, PANEL, bold=True))
|
|
275
|
+
c.put(x + max(7, (w - len(label)) // 2), y, label, Style(MUTED, PANEL), w - 14)
|
|
276
|
+
c.put(x + w - 5, y, f"{away:<5}", Style(BLUE, PANEL, bold=True))
|
|
277
|
+
try:
|
|
278
|
+
hv, av = float(home), float(away)
|
|
279
|
+
except ValueError:
|
|
280
|
+
hv = av = 0
|
|
281
|
+
total = max(hv + av, 1)
|
|
282
|
+
bar_w = max(4, w - 14)
|
|
283
|
+
home_w = int(bar_w * hv / total)
|
|
284
|
+
c.put(x + 7, y + 1, "━" * home_w, Style(GREEN, PANEL))
|
|
285
|
+
c.put(x + 7 + home_w, y + 1, "━" * (bar_w - home_w), Style(BLUE, PANEL))
|
|
286
|
+
|
|
287
|
+
def _stats(self, c: Canvas, y: int, h: int) -> None:
|
|
288
|
+
c.box(1, y, c.width - 2, h, "Team statistics")
|
|
289
|
+
c.put(4, y + 1, self.state.home.name, Style(GREEN, PANEL, bold=True), c.width // 3)
|
|
290
|
+
away = self.state.away.name
|
|
291
|
+
c.put(max(4, c.width - len(away) - 4), y + 1, away, Style(BLUE, PANEL, bold=True))
|
|
292
|
+
preferred = list(STAT_LABELS)
|
|
293
|
+
keys = [key for key in preferred if key in self.state.stats]
|
|
294
|
+
keys += [key for key in self.state.stats if key not in keys]
|
|
295
|
+
row = y + 3
|
|
296
|
+
for key in keys[self.scroll:]:
|
|
297
|
+
if row >= y + h - 1:
|
|
298
|
+
break
|
|
299
|
+
self._stat_row(c, 4, row, c.width - 8, STAT_LABELS.get(key, key.replace("_", " ").title()), self.state.stats[key])
|
|
300
|
+
row += 2
|
|
301
|
+
if not keys:
|
|
302
|
+
self._empty(c, 1, y, c.width - 2, h, "Stats will appear as play develops")
|
|
303
|
+
|
|
304
|
+
def _lineups(self, c: Canvas, y: int, h: int) -> None:
|
|
305
|
+
gap = 1
|
|
306
|
+
width = (c.width - 3 - gap) // 2
|
|
307
|
+
self._squad(c, 1, y, width, h, "home")
|
|
308
|
+
self._squad(c, 2 + width, y, c.width - width - 3, h, "away")
|
|
309
|
+
|
|
310
|
+
def _squad(self, c: Canvas, x: int, y: int, w: int, h: int, side: str) -> None:
|
|
311
|
+
team = self.state.home if side == "home" else self.state.away
|
|
312
|
+
accent = GREEN if side == "home" else BLUE
|
|
313
|
+
c.box(x, y, w, h, f"{team.name} · {self.state.formations.get(side, '')}")
|
|
314
|
+
starters = self.state.lineups.get("lineups", {}).get(side, [])
|
|
315
|
+
bench = self.state.lineups.get("bench", {}).get(side, [])
|
|
316
|
+
rows = [("STARTING XI", None)] + [("", p) for p in starters] + [("BENCH", None)] + [("", p) for p in bench]
|
|
317
|
+
row = y + 2
|
|
318
|
+
for label, player in rows[self.scroll:]:
|
|
319
|
+
if row >= y + h - 1:
|
|
320
|
+
break
|
|
321
|
+
if player is None:
|
|
322
|
+
c.put(x + 2, row, label, Style(MUTED, PANEL, bold=True), w - 4)
|
|
323
|
+
else:
|
|
324
|
+
number = str(player.get("sn", ""))
|
|
325
|
+
name = str(player.get("pn") or player.get("pns") or "—")
|
|
326
|
+
c.put(x + 2, row, f"{number:>2}", Style(accent, PANEL, bold=True))
|
|
327
|
+
c.put(x + 6, row, name, Style(TEXT, PANEL), w - 8)
|
|
328
|
+
row += 1
|
|
329
|
+
if not starters:
|
|
330
|
+
self._empty(c, x, y, w, h, "Lineups not announced")
|
|
331
|
+
|
|
332
|
+
def _formation(self, c: Canvas, y: int, h: int) -> None:
|
|
333
|
+
gap = 1
|
|
334
|
+
width = (c.width - 3 - gap) // 2
|
|
335
|
+
self._pitch(c, 1, y, width, h, "home")
|
|
336
|
+
self._pitch(c, 2 + width, y, c.width - width - 3, h, "away")
|
|
337
|
+
|
|
338
|
+
def _pitch(self, c: Canvas, x: int, y: int, w: int, h: int, side: str) -> None:
|
|
339
|
+
team = self.state.home if side == "home" else self.state.away
|
|
340
|
+
accent = GREEN if side == "home" else BLUE
|
|
341
|
+
formation = self.state.formations.get(side, "")
|
|
342
|
+
c.box(x, y, w, h, f"{team.name} · {formation or 'TBC'}")
|
|
343
|
+
players = self.state.lineups.get("lineups", {}).get(side, [])
|
|
344
|
+
if not players:
|
|
345
|
+
self._empty(c, x, y, w, h, "Formation not available")
|
|
346
|
+
return
|
|
347
|
+
inner_h = h - 3
|
|
348
|
+
c.fill(x + 2, y + 2, w - 4, inner_h, Style(TEXT, "#123323"))
|
|
349
|
+
c.put(x + 2, y + 2 + inner_h // 2, "─" * (w - 4), Style("#2d664a", "#123323"))
|
|
350
|
+
groups: list[list[dict]] = []
|
|
351
|
+
for player in players:
|
|
352
|
+
pos = int(player.get("so", 0) or 0)
|
|
353
|
+
while len(groups) <= pos:
|
|
354
|
+
groups.append([])
|
|
355
|
+
groups[pos].append(player)
|
|
356
|
+
groups = [group for group in groups if group]
|
|
357
|
+
for group_index, group in enumerate(groups):
|
|
358
|
+
py = y + 3 + int(group_index * max(1, inner_h - 2) / max(1, len(groups) - 1))
|
|
359
|
+
for index, player in enumerate(group):
|
|
360
|
+
name = str(player.get("pns") or player.get("pn") or "—")
|
|
361
|
+
if w < 48:
|
|
362
|
+
name = name.split()[-1][:8]
|
|
363
|
+
slot = (w - 6) / len(group)
|
|
364
|
+
px = x + 3 + int((index + 0.5) * slot - len(name) / 2)
|
|
365
|
+
c.put(px, py, name, Style(TEXT, "#123323", bold=True), int(slot))
|
|
366
|
+
c.put(max(x + 2, px - 2), py, "●", Style(accent, "#123323", bold=True))
|
|
367
|
+
|
|
368
|
+
def _players(self, c: Canvas, y: int, h: int) -> None:
|
|
369
|
+
c.box(1, y, c.width - 2, h, "Player statistics")
|
|
370
|
+
headers = "PLAYER TEAM MIN RATING xG G A"
|
|
371
|
+
c.put(3, y + 2, headers, Style(MUTED, PANEL, bold=True), c.width - 6)
|
|
372
|
+
row = y + 4
|
|
373
|
+
for player in self.state.player_stats[self.scroll:]:
|
|
374
|
+
if row >= y + h - 1:
|
|
375
|
+
break
|
|
376
|
+
name = str(player.get("pn") or player.get("participant_name") or "—")
|
|
377
|
+
team = str(player.get("tn") or player.get("team_name") or "—")
|
|
378
|
+
values = [player.get(key, player.get(f"sd_{key}", "0")) for key in ("min", "rating", "expected_goal", "goals", "assists")]
|
|
379
|
+
line = f"{name:<30} {team:<9} {str(values[0]):>4} {str(values[1]):>9} {str(values[2]):>6} {str(values[3]):>4} {str(values[4]):>4}"
|
|
380
|
+
c.put(3, row, line, Style(TEXT, PANEL), c.width - 6)
|
|
381
|
+
row += 1
|
|
382
|
+
if not self.state.player_stats:
|
|
383
|
+
self._empty(c, 1, y, c.width - 2, h, "Player ratings appear once enough match data is collected")
|
|
384
|
+
|
|
385
|
+
def _h2h(self, c: Canvas, y: int, h: int) -> None:
|
|
386
|
+
c.box(1, y, c.width - 2, h, "Head to head · last meetings")
|
|
387
|
+
games = self.state.h2h.get("h2h", [])
|
|
388
|
+
row = y + 2
|
|
389
|
+
for game in games[self.scroll:]:
|
|
390
|
+
if row >= y + h - 1:
|
|
391
|
+
break
|
|
392
|
+
date = game.get("date", "")
|
|
393
|
+
home, away = game.get("home", "—"), game.get("away", "—")
|
|
394
|
+
score = f"{game.get('home_score', '–')} : {game.get('away_score', '–')}"
|
|
395
|
+
c.put(3, row, date, Style(MUTED, PANEL))
|
|
396
|
+
centre = c.width // 2
|
|
397
|
+
c.put(max(15, centre - len(str(home)) - 6), row, home, Style(TEXT, PANEL, bold=True))
|
|
398
|
+
c.put(centre - 3, row, score, Style(LIME, PANEL, bold=True))
|
|
399
|
+
c.put(centre + 5, row, away, Style(TEXT, PANEL, bold=True), c.width - centre - 8)
|
|
400
|
+
row += 2
|
|
401
|
+
if not games:
|
|
402
|
+
self._empty(c, 1, y, c.width - 2, h, "No previous meetings found")
|
|
403
|
+
|
|
404
|
+
def _bracket(self, c: Canvas, y: int, h: int) -> None:
|
|
405
|
+
c.box(1, y, c.width - 2, h, "Road to the final")
|
|
406
|
+
rounds = sorted(self.state.bracket.items(), key=lambda item: -len(item[1]))
|
|
407
|
+
if not rounds:
|
|
408
|
+
self._empty(c, 1, y, c.width - 2, h, "Tournament bracket not available")
|
|
409
|
+
return
|
|
410
|
+
columns = min(len(rounds), 3 if c.width >= 100 else 2)
|
|
411
|
+
col_w = (c.width - 6) // columns
|
|
412
|
+
for col, (round_name, games) in enumerate(rounds[-columns:]):
|
|
413
|
+
x = 3 + col * col_w
|
|
414
|
+
c.put(x, y + 2, round_name.upper(), Style(MUTED, PANEL, bold=True), col_w - 2)
|
|
415
|
+
row = y + 4
|
|
416
|
+
for game in games:
|
|
417
|
+
if row + 2 >= y + h:
|
|
418
|
+
break
|
|
419
|
+
home = str(game.get("home", "—"))
|
|
420
|
+
away = str(game.get("away", "—"))
|
|
421
|
+
c.put(x, row, home, Style(TEXT, PANEL), col_w - 7)
|
|
422
|
+
c.put(x + col_w - 5, row, str(game.get("home_score", "–")), Style(LIME, PANEL, bold=True))
|
|
423
|
+
c.put(x, row + 1, away, Style(TEXT, PANEL), col_w - 7)
|
|
424
|
+
c.put(x + col_w - 5, row + 1, str(game.get("away_score", "–")), Style(LIME, PANEL, bold=True))
|
|
425
|
+
c.put(x, row + 2, "─" * max(2, col_w - 3), Style(LINE, PANEL))
|
|
426
|
+
row += 4
|
|
427
|
+
|
|
428
|
+
def _empty(self, c: Canvas, x: int, y: int, w: int, h: int, message: str) -> None:
|
|
429
|
+
px = max(x + 2, x + (w - len(message)) // 2)
|
|
430
|
+
c.put(px, y + h // 2, message, Style(MUTED, PANEL), w - 4)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def strip_ansi(value: str) -> str:
|
|
434
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", value)
|