sportsing 0.1.2 → 0.2.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/README.md +160 -12
- package/package.json +6 -2
- package/src/alerts.ts +47 -0
- package/src/api.ts +1 -1
- package/src/click-to-watch.ts +39 -0
- package/src/commands/_lib.ts +28 -12
- package/src/commands/analyze.ts +2 -2
- package/src/commands/ask.ts +1 -1
- package/src/commands/cross.ts +354 -0
- package/src/commands/fav.ts +19 -16
- package/src/commands/league-ai.ts +201 -0
- package/src/commands/league-bracket.ts +150 -0
- package/src/commands/league.ts +839 -0
- package/src/commands/live.ts +57 -42
- package/src/commands/me.ts +1 -1
- package/src/commands/predict.ts +2 -2
- package/src/commands/scorers.ts +1 -1
- package/src/commands/setup.ts +3 -3
- package/src/commands/subscriptions.ts +66 -0
- package/src/commands/table.ts +1 -1
- package/src/commands/watch.ts +19 -11
- package/src/config.ts +166 -28
- package/src/espn.ts +606 -33
- package/src/format.ts +87 -0
- package/src/game-events.ts +239 -0
- package/src/game.ts +51 -0
- package/src/index.ts +35 -16
- package/src/league-ai.ts +359 -0
- package/src/league-alerts.ts +139 -0
- package/src/league-detect.ts +104 -0
- package/src/league-overlay.ts +351 -0
- package/src/league-panels.ts +159 -0
- package/src/overlay.ts +96 -51
- package/src/playoff-bracket.ts +384 -0
- package/src/recap.ts +37 -8
- package/src/season.ts +182 -0
- package/src/sports/fifa.ts +9 -4
- package/src/sports/nba.ts +20 -0
- package/src/sports/nhl.ts +20 -0
- package/src/standings.ts +130 -0
- package/src/stream.ts +49 -8
- package/src/watch-route.ts +95 -0
- package/src/watchability.ts +183 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
// The shared command set for ESPN-backed leagues (NBA, NHL, …). A league is
|
|
2
|
+
// pure configuration — its ESPN path, its favorites key, and display labels —
|
|
3
|
+
// so adding one is a `LeagueConfig` plus a src/sports/<sport>.ts that calls
|
|
4
|
+
// `leagueNamespace`. Nothing in here branches on a particular league.
|
|
5
|
+
//
|
|
6
|
+
// Teams are matched by ESPN team id, never abbreviation: abbreviations differ
|
|
7
|
+
// by endpoint (the Mammoth are UTAH in /teams but UTA on schedules). User input
|
|
8
|
+
// (`--team`, favorites) is resolved to a team via the league's /teams list.
|
|
9
|
+
|
|
10
|
+
import { c } from "../ansi.ts";
|
|
11
|
+
import { getFavorites, getHomeMarket, getStreamProvider, getSubscriptions } from "../config.ts";
|
|
12
|
+
import { getGameSummary, getScoreboardGames, getStandings, getTeamGames, getTeams, SEASON_TYPES, type EspnTeam, type League } from "../espn.ts";
|
|
13
|
+
import { fmtDate, fmtDayHeader, gameLine, relativeTime, type PeriodNaming } from "../format.ts";
|
|
14
|
+
import type { Game } from "../game.ts";
|
|
15
|
+
import { PLAYOFF_FORMATS, renderSeasonSummary, summarizeSeason } from "../season.ts";
|
|
16
|
+
import { STANDINGS_LAYOUTS, groupMatches, loadStandingsView, renderStandingsTable, type StandingsLevel } from "../standings.ts";
|
|
17
|
+
import { launchStream, pickProvider } from "../stream.ts";
|
|
18
|
+
import {
|
|
19
|
+
SUBSCRIPTIONS_HINT,
|
|
20
|
+
isWatchSport,
|
|
21
|
+
planWatch,
|
|
22
|
+
watchCell,
|
|
23
|
+
watchHeading,
|
|
24
|
+
watchOf,
|
|
25
|
+
watchSummary,
|
|
26
|
+
withWatchColumn,
|
|
27
|
+
type WatchContext,
|
|
28
|
+
} from "../watch-route.ts";
|
|
29
|
+
import { runLeagueOverlay, smokeLeagueOverlay } from "../league-overlay.ts";
|
|
30
|
+
import { EXAMPLE_TEAM, addDays, getFlag, localDateOf, mineFavorites, noFavoritesHint, ymd } from "./_lib.ts";
|
|
31
|
+
import { fav } from "./fav.ts";
|
|
32
|
+
import { leagueAnalyze, leaguePredict, leagueRecap, type LeagueGames } from "./league-ai.ts";
|
|
33
|
+
import { fmtEta, parseSize, positionalTerms, smokeWatch, waitPollMs } from "./watch.ts";
|
|
34
|
+
import { leagueBracket } from "./league-bracket.ts";
|
|
35
|
+
import { LIVE_REFRESH_MS, raise, type Alert, type Alerter } from "../alerts.ts";
|
|
36
|
+
import { selfInvocation } from "../click-to-watch.ts";
|
|
37
|
+
import { isGameSport } from "../game-events.ts";
|
|
38
|
+
import { leagueFeed } from "../league-alerts.ts";
|
|
39
|
+
|
|
40
|
+
export interface LeagueConfig {
|
|
41
|
+
/** CLI namespace and favorites key, e.g. "nba" (`sportsing nba …`, `nba:UTAH`). */
|
|
42
|
+
sport: string;
|
|
43
|
+
/** ESPN league path. */
|
|
44
|
+
league: League;
|
|
45
|
+
/** Display name, e.g. "NBA". */
|
|
46
|
+
label: string;
|
|
47
|
+
/** Header glyph, e.g. "🏀". */
|
|
48
|
+
icon: string;
|
|
49
|
+
/** Extra accepted team abbreviations → the ESPN /teams abbreviation
|
|
50
|
+
* (e.g. NBA.com's "UTA" → ESPN's "UTAH"). */
|
|
51
|
+
aliases: Record<string, string>;
|
|
52
|
+
/** Default streaming provider key (see PROVIDERS in stream.ts) for
|
|
53
|
+
* `<sport> watch` when config sets none; absent = no default. */
|
|
54
|
+
watchProvider?: string;
|
|
55
|
+
/** League-specific period names for live/final status (hockey: 1st/2nd/3rd/
|
|
56
|
+
* OT/SO). Omitted → the source's status text is shown as-is. */
|
|
57
|
+
periods?: PeriodNaming;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How far `schedule` looks ahead / `results` looks back league-wide, and how
|
|
61
|
+
* far `next` scans for a game, in days. ESPN date ranges are unreliable for
|
|
62
|
+
* NBA/NHL, so each day is its own (cached) scoreboard request. */
|
|
63
|
+
const WINDOW_DAYS = 7;
|
|
64
|
+
const NEXT_SCAN_DAYS = 14;
|
|
65
|
+
|
|
66
|
+
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve user input to one of the league's teams: ESPN id, abbreviation, or a
|
|
70
|
+
* configured alias (case-insensitive); then full name or nickname ("Utah Jazz",
|
|
71
|
+
* "Jazz"); then location ("Utah") when exactly one team has it. Null if
|
|
72
|
+
* nothing (or nothing unambiguous) matches.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveTeam(teams: EspnTeam[], input: string, aliases: Record<string, string> = {}): EspnTeam | null {
|
|
75
|
+
const q = input.trim().toLowerCase();
|
|
76
|
+
if (!q) return null;
|
|
77
|
+
const aliasOf = Object.entries(aliases).find(([k]) => k.toLowerCase() === q)?.[1]?.toLowerCase();
|
|
78
|
+
const byCode = teams.find((t) => t.id === q || t.abbreviation.toLowerCase() === q || t.abbreviation.toLowerCase() === aliasOf);
|
|
79
|
+
if (byCode) return byCode;
|
|
80
|
+
const byName = teams.find((t) => t.name.toLowerCase() === q || t.shortName.toLowerCase() === q);
|
|
81
|
+
if (byName) return byName;
|
|
82
|
+
const byLocation = teams.filter((t) => t.location.toLowerCase() === q);
|
|
83
|
+
return byLocation.length === 1 ? byLocation[0]! : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True if either side of `g` is one of the team ids. */
|
|
87
|
+
export function gameHasTeam(g: Game, ids: ReadonlySet<string>): boolean {
|
|
88
|
+
return ids.has(g.home.id) || ids.has(g.away.id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Merge game lists (e.g. several days, or several teams' schedules): one copy
|
|
92
|
+
* per game id, sorted by start time ascending. */
|
|
93
|
+
export function mergeGames(lists: Game[][]): Game[] {
|
|
94
|
+
const byId = new Map<string, Game>();
|
|
95
|
+
for (const list of lists) for (const g of list) if (!byId.has(g.id)) byId.set(g.id, g);
|
|
96
|
+
return [...byId.values()].sort((a, b) => Date.parse(a.date) - Date.parse(b.date));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** ESPN scoreboard date (YYYYMMDD) for a local calendar day. */
|
|
100
|
+
export function espnDate(d: Date): string {
|
|
101
|
+
return ymd(d).replace(/-/g, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The first not-yet-started game at or after `now` (input sorted ascending). */
|
|
105
|
+
export function firstUpcoming(games: Game[], now: number): Game | null {
|
|
106
|
+
return games.find((g) => g.state === "pre" && Date.parse(g.date) >= now) ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** How long after its scheduled start a not-yet-live game still counts as the
|
|
110
|
+
* one to watch (late tip-offs, a lagging feed) before `watch` moves past it. */
|
|
111
|
+
const START_GRACE_MS = 3 * 60 * 60_000;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The game `watch` is about, from games sorted ascending: a live one if any,
|
|
115
|
+
* else the first scheduled game that hasn't started — or is within
|
|
116
|
+
* START_GRACE_MS past its start and simply hasn't flipped live yet (so
|
|
117
|
+
* `--wait` doesn't skip a late tip-off for the following game).
|
|
118
|
+
*/
|
|
119
|
+
export function watchTarget(games: Game[], now: number): Game | null {
|
|
120
|
+
return (
|
|
121
|
+
games.find((g) => g.state === "in") ??
|
|
122
|
+
games.find((g) => g.state === "pre" && Date.parse(g.date) >= now - START_GRACE_MS) ??
|
|
123
|
+
null
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** ESPN scoreboard date (YYYYMMDD) a game is listed under — scoreboards are
|
|
128
|
+
* grouped by US-Eastern day, whatever the local time zone. */
|
|
129
|
+
export function easternScoreboardDate(iso: string): string {
|
|
130
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
131
|
+
timeZone: "America/New_York",
|
|
132
|
+
year: "numeric",
|
|
133
|
+
month: "2-digit",
|
|
134
|
+
day: "2-digit",
|
|
135
|
+
}).formatToParts(new Date(iso));
|
|
136
|
+
const get = (t: string) => parts.find((p) => p.type === t)!.value;
|
|
137
|
+
return get("year") + get("month") + get("day");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Finished games, newest first. */
|
|
141
|
+
export function finishedNewestFirst(games: Game[]): Game[] {
|
|
142
|
+
return games.filter((g) => g.state === "post").sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What the live board shows: games in play (whatever day they started),
|
|
146
|
+
* and today's not-yet-started and finished games, each ascending. */
|
|
147
|
+
export function liveBoard(games: Game[], today: string): { live: Game[]; upcoming: Game[]; done: Game[] } {
|
|
148
|
+
const sorted = mergeGames([games]);
|
|
149
|
+
const isToday = (g: Game) => localDateOf(g.date) === today;
|
|
150
|
+
return {
|
|
151
|
+
live: sorted.filter((g) => g.state === "in"),
|
|
152
|
+
upcoming: sorted.filter((g) => g.state === "pre" && isToday(g)),
|
|
153
|
+
done: sorted.filter((g) => g.state === "post" && isToday(g)),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Data access ──────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/** Every game for one team across preseason, regular season, and postseason. */
|
|
160
|
+
export async function teamSeason(cfg: LeagueConfig, teamId: string): Promise<Game[]> {
|
|
161
|
+
const { preseason, regular, postseason } = SEASON_TYPES;
|
|
162
|
+
const lists = await Promise.all([preseason, regular, postseason].map((st) => getTeamGames(cfg.league, teamId, st)));
|
|
163
|
+
return mergeGames(lists);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** League-wide games on each local day in [from, from + days). ESPN groups
|
|
167
|
+
* scoreboards by US-Eastern day, so the days either side are fetched too and
|
|
168
|
+
* every game is re-bucketed by its local start date. */
|
|
169
|
+
export async function gamesOnDays(cfg: LeagueConfig, from: Date, days: number): Promise<Game[]> {
|
|
170
|
+
const fetchDays = Array.from({ length: days + 2 }, (_, i) => addDays(from, i - 1));
|
|
171
|
+
const lists = await Promise.all(fetchDays.map((d) => getScoreboardGames(cfg.league, espnDate(d))));
|
|
172
|
+
const wanted = new Set(Array.from({ length: days }, (_, i) => ymd(addDays(from, i))));
|
|
173
|
+
return mergeGames(lists).filter((g) => wanted.has(localDateOf(g.date)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** How fresh live polls are: below LIVE_REFRESH_MS so every refresh refetches. */
|
|
177
|
+
const LIVE_TTL_MS = 20_000;
|
|
178
|
+
|
|
179
|
+
/** Games for the live board and alerts: the scoreboards for local yesterday,
|
|
180
|
+
* today, and tomorrow (ESPN groups by US-Eastern day), unfiltered — a game
|
|
181
|
+
* that runs past midnight stays in view until it goes final. */
|
|
182
|
+
async function liveGames(cfg: LeagueConfig): Promise<Game[]> {
|
|
183
|
+
const now = new Date();
|
|
184
|
+
const lists = await Promise.all([-1, 0, 1].map((i) => getScoreboardGames(cfg.league, espnDate(addDays(now, i)), LIVE_TTL_MS)));
|
|
185
|
+
return mergeGames(lists);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Throws a user-facing error for unknown team input. */
|
|
189
|
+
function unknownTeam(cfg: LeagueConfig, input: string): never {
|
|
190
|
+
const example = EXAMPLE_TEAM[cfg.sport] ?? "<abbr>";
|
|
191
|
+
throw new Error(`Unknown ${cfg.label} team "${input}". Use an ESPN abbreviation (e.g. ${example}) or a team name.`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type Scope =
|
|
195
|
+
| { kind: "league" }
|
|
196
|
+
| { kind: "teams"; ids: Set<string>; label: string }
|
|
197
|
+
| { kind: "no-favorites" };
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Which teams a command is about: `--team X` (one resolved team), `--mine`
|
|
201
|
+
* (the league's favorites, unresolvable ones warned about and skipped), or the
|
|
202
|
+
* whole league. `--team` wins over `--mine`.
|
|
203
|
+
*/
|
|
204
|
+
async function scopeOf(cfg: LeagueConfig, args: string[]): Promise<Scope> {
|
|
205
|
+
const teamArg = getFlag(args, "--team");
|
|
206
|
+
const favs = teamArg ? null : await mineFavorites(args, cfg.sport);
|
|
207
|
+
if (!teamArg && favs === null) return { kind: "league" };
|
|
208
|
+
if (favs === "no-favorites") return { kind: "no-favorites" };
|
|
209
|
+
|
|
210
|
+
const teams = await getTeams(cfg.league);
|
|
211
|
+
if (teamArg) {
|
|
212
|
+
const t = resolveTeam(teams, teamArg, cfg.aliases) ?? unknownTeam(cfg, teamArg);
|
|
213
|
+
return { kind: "teams", ids: new Set([t.id]), label: t.name };
|
|
214
|
+
}
|
|
215
|
+
return { kind: "teams", ids: favoriteIds(cfg, teams, favs!), label: "your favorites" };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Resolve favorite names to team ids; unresolvable ones are warned about and skipped. */
|
|
219
|
+
export function favoriteIds(cfg: LeagueConfig, teams: EspnTeam[], favs: string[]): Set<string> {
|
|
220
|
+
const ids = new Set<string>();
|
|
221
|
+
for (const f of favs) {
|
|
222
|
+
const t = resolveTeam(teams, f, cfg.aliases);
|
|
223
|
+
if (t) ids.add(t.id);
|
|
224
|
+
else console.error(c.yellow(`Skipping favorite "${f}" — not a ${cfg.label} team.`));
|
|
225
|
+
}
|
|
226
|
+
return ids;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Every game for the scoped teams' full seasons. */
|
|
230
|
+
async function scopedSeasons(cfg: LeagueConfig, ids: Set<string>): Promise<Game[]> {
|
|
231
|
+
return mergeGames(await Promise.all([...ids].map((id) => teamSeason(cfg, id))));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Output ───────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
function title(cfg: LeagueConfig, text: string): void {
|
|
237
|
+
console.log(c.bold(c.cyan(`${cfg.icon} ${cfg.label} — ${text}`)));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Subscriptions + home market for the WATCH column / `watch` routing; null
|
|
241
|
+
* when none are configured (nothing to resolve against). */
|
|
242
|
+
async function watchContext(cfg: LeagueConfig): Promise<WatchContext | null> {
|
|
243
|
+
if (!isWatchSport(cfg.sport)) return null;
|
|
244
|
+
const [subscriptions, homeMarket] = await Promise.all([getSubscriptions(), getHomeMarket()]);
|
|
245
|
+
return subscriptions.length ? { sport: cfg.sport, subscriptions, homeMarket } : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The provider `watch` opens when the resolver can't decide (or isn't set
|
|
249
|
+
* up): the configured one for the sport, else the league default. */
|
|
250
|
+
async function watchFallback(cfg: LeagueConfig): Promise<string | null> {
|
|
251
|
+
return (await getStreamProvider(cfg.sport)) ?? cfg.watchProvider ?? null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Game lines, with an aligned WATCH column when `watch` is given. */
|
|
255
|
+
function gameLines(cfg: LeagueConfig, games: Game[], watch: WatchContext | null): { lines: string[]; column: number | null } {
|
|
256
|
+
const lines = games.map((g) => " " + gameLine(g, cfg.periods));
|
|
257
|
+
if (!watch) return { lines, column: null };
|
|
258
|
+
return withWatchColumn(lines, games.map((g) => watchCell(g, watch)));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Print games (in the given order), under local-day headers when `byDay`.
|
|
262
|
+
* With a WatchContext the WATCH column is added; without one (no
|
|
263
|
+
* subscriptions set), a hint on how to get it — unless `watch` is "off". */
|
|
264
|
+
function printGames(cfg: LeagueConfig, games: Game[], watch: WatchContext | null | "off", byDay: boolean): void {
|
|
265
|
+
const { lines, column } = gameLines(cfg, games, watch === "off" ? null : watch);
|
|
266
|
+
const heading = (text: string) => (column === null ? text : watchHeading(text, column));
|
|
267
|
+
if (!byDay) console.log(column === null ? "" : "\n" + heading(""));
|
|
268
|
+
let currentDay = "";
|
|
269
|
+
games.forEach((g, i) => {
|
|
270
|
+
const day = localDateOf(g.date);
|
|
271
|
+
if (byDay && day !== currentDay) {
|
|
272
|
+
const header = c.bold(fmtDayHeader(g.date));
|
|
273
|
+
console.log("\n" + (currentDay === "" ? heading(header) : header)); // column heading once, on the first day
|
|
274
|
+
currentDay = day;
|
|
275
|
+
}
|
|
276
|
+
console.log(lines[i]);
|
|
277
|
+
});
|
|
278
|
+
if (watch === null) console.log("\n" + c.dim(SUBSCRIPTIONS_HINT));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Commands ─────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
export function parseOffset(args: string[]): number {
|
|
284
|
+
if (args.includes("--tomorrow")) return 1;
|
|
285
|
+
if (args.includes("--yesterday")) return -1;
|
|
286
|
+
const v = getFlag(args, "--offset");
|
|
287
|
+
return v ? parseInt(v, 10) || 0 : 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `today [--tomorrow|--yesterday|--offset N] [--mine]` — one local day's games. */
|
|
291
|
+
async function today(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
292
|
+
const offset = parseOffset(args);
|
|
293
|
+
const day = addDays(new Date(), offset);
|
|
294
|
+
const scope = await scopeOf(cfg, args);
|
|
295
|
+
if (scope.kind === "no-favorites") return noFavoritesHint(cfg.sport);
|
|
296
|
+
|
|
297
|
+
let [games, watch] = await Promise.all([gamesOnDays(cfg, day, 1), watchContext(cfg)]);
|
|
298
|
+
if (scope.kind === "teams") games = games.filter((g) => gameHasTeam(g, scope.ids));
|
|
299
|
+
|
|
300
|
+
const label = offset === 0 ? "Today" : offset === 1 ? "Tomorrow" : offset === -1 ? "Yesterday" : ymd(day);
|
|
301
|
+
title(cfg, `${label} (${ymd(day)})`);
|
|
302
|
+
if (games.length === 0) {
|
|
303
|
+
console.log(c.dim("\nNo games scheduled."));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
printGames(cfg, games, watch, false);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** `next [--team X] [--mine]` — the next game to start, with a countdown. */
|
|
310
|
+
async function next(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
311
|
+
const scope = await scopeOf(cfg, args);
|
|
312
|
+
if (scope.kind === "no-favorites") return noFavoritesHint(cfg.sport);
|
|
313
|
+
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
let g: Game | null = null;
|
|
316
|
+
if (scope.kind === "teams") {
|
|
317
|
+
g = firstUpcoming(await scopedSeasons(cfg, scope.ids), now);
|
|
318
|
+
} else {
|
|
319
|
+
// League-wide: walk forward a day at a time until a game turns up.
|
|
320
|
+
for (let i = 0; i < NEXT_SCAN_DAYS && !g; i++) {
|
|
321
|
+
g = firstUpcoming(await gamesOnDays(cfg, addDays(new Date(), i), 1), now);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!g) {
|
|
326
|
+
const within = scope.kind === "league" ? ` in the next ${NEXT_SCAN_DAYS} days` : "";
|
|
327
|
+
console.log(c.dim(`No upcoming ${cfg.label} games${scope.kind === "teams" ? ` for ${scope.label}` : ""}${within}.`));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const watch = await watchContext(cfg);
|
|
331
|
+
title(cfg, "Next Game");
|
|
332
|
+
printGames(cfg, [g], watch ?? "off", false);
|
|
333
|
+
console.log(c.dim(" " + g.name));
|
|
334
|
+
console.log(` ${c.bold(fmtDate(g.date))} ${c.green("— starts " + relativeTime(g.date))}`);
|
|
335
|
+
console.log(watch ? ` 📺 ${watchSummary(watchOf(g, watch))}` : c.dim(" " + SUBSCRIPTIONS_HINT));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** `schedule [--team X | --mine]` — a team's (or your favorites') whole season,
|
|
339
|
+
* preseason through postseason; league-wide, the next week. */
|
|
340
|
+
async function schedule(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
341
|
+
const scope = await scopeOf(cfg, args);
|
|
342
|
+
if (scope.kind === "no-favorites") return noFavoritesHint(cfg.sport);
|
|
343
|
+
|
|
344
|
+
const [games, watch] = await Promise.all([
|
|
345
|
+
scope.kind === "teams" ? scopedSeasons(cfg, scope.ids) : gamesOnDays(cfg, new Date(), WINDOW_DAYS),
|
|
346
|
+
watchContext(cfg),
|
|
347
|
+
]);
|
|
348
|
+
const what = scope.kind === "teams" ? scope.label : `next ${WINDOW_DAYS} days`;
|
|
349
|
+
title(cfg, `Schedule — ${what} (${games.length})`);
|
|
350
|
+
if (games.length === 0) {
|
|
351
|
+
console.log(c.dim("\nNo games to show."));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
printGames(cfg, games, watch, true);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `results [--team X | --mine]` — finished games, newest first; league-wide,
|
|
358
|
+
* the past week. */
|
|
359
|
+
async function results(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
360
|
+
const scope = await scopeOf(cfg, args);
|
|
361
|
+
if (scope.kind === "no-favorites") return noFavoritesHint(cfg.sport);
|
|
362
|
+
|
|
363
|
+
const pool =
|
|
364
|
+
scope.kind === "teams"
|
|
365
|
+
? await scopedSeasons(cfg, scope.ids)
|
|
366
|
+
: await gamesOnDays(cfg, addDays(new Date(), -(WINDOW_DAYS - 1)), WINDOW_DAYS);
|
|
367
|
+
const games = finishedNewestFirst(pool);
|
|
368
|
+
const what = scope.kind === "teams" ? scope.label : `last ${WINDOW_DAYS} days`;
|
|
369
|
+
title(cfg, `Results — ${what} (${games.length})`);
|
|
370
|
+
if (games.length === 0) {
|
|
371
|
+
console.log(c.dim("\nNo finished games yet."));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
printGames(cfg, games, "off", true);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** `standings [--conference X | --division X]` — regular-season standings,
|
|
378
|
+
* favorites highlighted. Tables are the league's default level (NBA
|
|
379
|
+
* conferences, NHL divisions); a filter switches to that level and shows the
|
|
380
|
+
* one matching table. Before the regular season, last season's final
|
|
381
|
+
* standings, labelled as such. */
|
|
382
|
+
async function standings(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
383
|
+
const layout = STANDINGS_LAYOUTS[cfg.league];
|
|
384
|
+
if (!layout) throw new Error(`No standings layout for ${cfg.label}.`);
|
|
385
|
+
const conference = getFlag(args, "--conference");
|
|
386
|
+
const division = getFlag(args, "--division");
|
|
387
|
+
if (conference && division) throw new Error("Use --conference or --division, not both.");
|
|
388
|
+
const filter = conference ?? division;
|
|
389
|
+
const level: StandingsLevel = conference ? "conference" : division ? "division" : layout.level;
|
|
390
|
+
|
|
391
|
+
const [view, favs] = await Promise.all([
|
|
392
|
+
loadStandingsView((q) => getStandings(cfg.league, q), level),
|
|
393
|
+
getFavorites(cfg.sport),
|
|
394
|
+
]);
|
|
395
|
+
if (view.kind === "not-started") {
|
|
396
|
+
title(cfg, `Standings${view.upcoming ? ` ${view.upcoming}` : ""}`);
|
|
397
|
+
console.log(c.dim("\nThe regular season hasn't started yet, and ESPN has no earlier standings to show."));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const { standings: data } = view;
|
|
402
|
+
const groups = filter ? data.groups.filter((g) => groupMatches(g, filter)) : data.groups;
|
|
403
|
+
if (groups.length === 0) {
|
|
404
|
+
const names = data.groups.map((g) => g.abbreviation || g.name).join(", ");
|
|
405
|
+
throw new Error(`No ${cfg.label} ${level} "${filter}". Try one of: ${names}.`);
|
|
406
|
+
}
|
|
407
|
+
const ids = favs.length > 0 ? favoriteIds(cfg, await getTeams(cfg.league), favs) : new Set<string>();
|
|
408
|
+
|
|
409
|
+
title(cfg, `Standings ${data.seasonName}${view.kind === "last-season" ? " (final)" : ""}`);
|
|
410
|
+
if (view.kind === "last-season") {
|
|
411
|
+
const upcoming = view.upcoming ? `The ${view.upcoming} regular season` : "The regular season";
|
|
412
|
+
console.log(c.yellow(`${upcoming} hasn't started — showing last season's final standings.`));
|
|
413
|
+
}
|
|
414
|
+
for (const g of groups) console.log("\n" + renderStandingsTable(g, layout, ids));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** `season [team]` — each favorite's (or the named team's) season at a glance:
|
|
418
|
+
* record, splits, conference/division position, playoff race. Before the
|
|
419
|
+
* regular season, last season's final standing, labelled as such. */
|
|
420
|
+
async function season(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
421
|
+
const layout = STANDINGS_LAYOUTS[cfg.league];
|
|
422
|
+
const format = PLAYOFF_FORMATS[cfg.league];
|
|
423
|
+
if (!layout || !format) throw new Error(`No season view for ${cfg.label}.`);
|
|
424
|
+
const input = args.join(" ").trim();
|
|
425
|
+
const teams = await getTeams(cfg.league);
|
|
426
|
+
let ids: Set<string>;
|
|
427
|
+
if (input) {
|
|
428
|
+
ids = new Set([(resolveTeam(teams, input, cfg.aliases) ?? unknownTeam(cfg, input)).id]);
|
|
429
|
+
} else {
|
|
430
|
+
const favs = await getFavorites(cfg.sport);
|
|
431
|
+
if (favs.length === 0) return noFavoritesHint(cfg.sport);
|
|
432
|
+
ids = favoriteIds(cfg, teams, favs);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const view = await loadStandingsView((q) => getStandings(cfg.league, q), "division");
|
|
436
|
+
if (view.kind === "not-started") {
|
|
437
|
+
title(cfg, `Season${view.upcoming ? ` ${view.upcoming}` : ""}`);
|
|
438
|
+
console.log(c.dim("\nThe regular season hasn't started yet, and ESPN has no earlier standings to show."));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const { standings: data } = view;
|
|
442
|
+
title(cfg, `Season ${data.seasonName}${view.kind === "last-season" ? " (final)" : ""}`);
|
|
443
|
+
if (view.kind === "last-season") {
|
|
444
|
+
const upcoming = view.upcoming ? `The ${view.upcoming} regular season` : "The regular season";
|
|
445
|
+
console.log(c.yellow(`${upcoming} hasn't started — showing last season's final standing.`));
|
|
446
|
+
}
|
|
447
|
+
for (const id of ids) {
|
|
448
|
+
const summary = summarizeSeason(data.groups, id, layout, format);
|
|
449
|
+
const name = teams.find((t) => t.id === id)?.name ?? id;
|
|
450
|
+
console.log("\n" + (summary ? renderSeasonSummary(summary) : c.dim(`${name}: not in the ${data.seasonName} standings.`)));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** `fav [add|rm|list] [team]` — `add` resolves the team against the league's
|
|
455
|
+
* /teams list and stores its ESPN abbreviation; list/rm are the shared fav. */
|
|
456
|
+
async function leagueFav(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
457
|
+
const [sub, ...rest] = args;
|
|
458
|
+
const input = rest.join(" ").trim();
|
|
459
|
+
if (sub === "add" && input) {
|
|
460
|
+
const t = resolveTeam(await getTeams(cfg.league), input, cfg.aliases) ?? unknownTeam(cfg, input);
|
|
461
|
+
return fav(["add", t.abbreviation], cfg.sport);
|
|
462
|
+
}
|
|
463
|
+
return fav(args, cfg.sport);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Who/what `watch` follows: a positional team, else the league's favorites. */
|
|
467
|
+
async function watchScope(cfg: LeagueConfig, args: string[]): Promise<{ ids: Set<string>; label: string } | null> {
|
|
468
|
+
const input = positionalTerms(args).join(" ").trim();
|
|
469
|
+
const teams = await getTeams(cfg.league);
|
|
470
|
+
if (input) {
|
|
471
|
+
const t = resolveTeam(teams, input, cfg.aliases) ?? unknownTeam(cfg, input);
|
|
472
|
+
return { ids: new Set([t.id]), label: t.name };
|
|
473
|
+
}
|
|
474
|
+
const favs = await getFavorites(cfg.sport);
|
|
475
|
+
const ids = favoriteIds(cfg, teams, favs);
|
|
476
|
+
return ids.size ? { ids, label: "your favorites" } : null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** `● LIVE`/start-time line for a game, with where it airs. */
|
|
480
|
+
function describeWatchGame(g: Game): string {
|
|
481
|
+
const on = g.broadcasts.length ? c.dim(` on ${g.broadcasts.map((b) => b.name).join(", ")}`) : "";
|
|
482
|
+
const when = g.state === "in" ? c.green("● LIVE") : c.dim(`${fmtDate(g.date)} — starts ${relativeTime(g.date)}`);
|
|
483
|
+
return `${g.name} ${when}${on}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Poll until the scoped teams' next game is live, then return it. The season
|
|
487
|
+
* schedule (5-min cache) picks the target; its day's scoreboard (short cache)
|
|
488
|
+
* gives the fresh state. Blocks until then — Ctrl-C to stop. */
|
|
489
|
+
async function waitForLeagueLive(cfg: LeagueConfig, ids: Set<string>, who: string): Promise<Game> {
|
|
490
|
+
console.log(c.bold(c.cyan(`⌛ Waiting for ${who}'s next game to go live…`)) + c.dim(" (Ctrl-C to stop)"));
|
|
491
|
+
let lastId = "";
|
|
492
|
+
for (;;) {
|
|
493
|
+
let target: Game | null = null;
|
|
494
|
+
try {
|
|
495
|
+
target = watchTarget(await scopedSeasons(cfg, ids), Date.now());
|
|
496
|
+
if (target && target.state !== "in") {
|
|
497
|
+
const day = await getScoreboardGames(cfg.league, easternScoreboardDate(target.date), 30_000);
|
|
498
|
+
target = day.find((g) => g.id === target!.id) ?? target;
|
|
499
|
+
}
|
|
500
|
+
} catch (e) {
|
|
501
|
+
console.error(c.dim(" (data fetch failed, retrying) " + (e instanceof Error ? e.message : String(e))));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (target?.state === "in") {
|
|
505
|
+
console.log(c.green(`● ${target.name} is LIVE — opening…`));
|
|
506
|
+
return target;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
let pollMs = 60_000;
|
|
510
|
+
if (!target) {
|
|
511
|
+
console.log(c.dim(` Nothing scheduled for ${who} yet — checking again in 60s.`));
|
|
512
|
+
} else {
|
|
513
|
+
if (target.id !== lastId) {
|
|
514
|
+
console.log(c.dim(` Next up: ${describeWatchGame(target)}`));
|
|
515
|
+
lastId = target.id;
|
|
516
|
+
}
|
|
517
|
+
const ms = Date.parse(target.date) - Date.now();
|
|
518
|
+
console.log(c.dim(ms > 0 ? ` starts in ${fmtEta(ms)}.` : " at/just past start — waiting for it to flip live."));
|
|
519
|
+
pollMs = waitPollMs(ms);
|
|
520
|
+
}
|
|
521
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Print what `watch` will do with `g` and return the provider key to open — or
|
|
527
|
+
* null when there's nothing to open: an over-the-air-only game (prints the
|
|
528
|
+
* channel; exit 0), or one that can't be watched / placed (says why; exit 1).
|
|
529
|
+
*/
|
|
530
|
+
function routeWatch(g: Game, ctx: WatchContext, fallback: string | null): string | null {
|
|
531
|
+
const plan = planWatch(watchOf(g, ctx), fallback);
|
|
532
|
+
if (plan.kind === "open") {
|
|
533
|
+
console.log(c.dim(`📺 ${plan.note}`));
|
|
534
|
+
return plan.provider;
|
|
535
|
+
}
|
|
536
|
+
if (plan.kind === "tune") {
|
|
537
|
+
console.log(c.green(`📺 ${plan.message}`) + c.dim(" (nothing to open; tune in)"));
|
|
538
|
+
} else {
|
|
539
|
+
console.error(c.yellow(`✗ ${plan.message}`) + c.dim(" (not opening a window)"));
|
|
540
|
+
process.exitCode = 1;
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** pickProvider, reporting a bad key (exit 1) instead of returning it. */
|
|
546
|
+
function pickOrReport(key: string, sport: string): { key: string; label: string; hub: string } | null {
|
|
547
|
+
const pick = pickProvider(key, sport);
|
|
548
|
+
if (pick.ok) return pick;
|
|
549
|
+
console.error(c.red(pick.error));
|
|
550
|
+
process.exitCode = 1;
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* `watch [team] [--wait] [--overlay] [--provider X] [--url L] [--size WxH] [--smoke]` —
|
|
556
|
+
* open the game's stream in that provider's persistent Chrome profile, so an
|
|
557
|
+
* existing login is reused. No team = your favorites.
|
|
558
|
+
*
|
|
559
|
+
* With subscriptions configured (`sportsing subscriptions`), the watchability
|
|
560
|
+
* resolver picks per game: Fubo or League Pass opens; an over-the-air-only game
|
|
561
|
+
* prints its channel and exits 0; an unwatchable one says why and opens
|
|
562
|
+
* nothing. A game it can't place falls back to the configured provider
|
|
563
|
+
* (`streamProviders`, else the league default). `--provider` / `--url` skip
|
|
564
|
+
* the resolver and open exactly what was asked; so does having no
|
|
565
|
+
* subscriptions set.
|
|
566
|
+
*
|
|
567
|
+
* `--wait` blocks until the next game is live, then opens it. `--smoke` opens
|
|
568
|
+
* the window, confirms it came up over CDP, and tears it down (bounded; for
|
|
569
|
+
* scripts and agents).
|
|
570
|
+
* `--overlay` adds the live-stats overlay, which follows the game you open.
|
|
571
|
+
*/
|
|
572
|
+
async function watch(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
573
|
+
const url = getFlag(args, "--url");
|
|
574
|
+
const providerFlag = getFlag(args, "--provider");
|
|
575
|
+
const sizeFlag = getFlag(args, "--size");
|
|
576
|
+
const windowSize = parseSize(sizeFlag);
|
|
577
|
+
if (sizeFlag && !windowSize) {
|
|
578
|
+
console.warn(c.yellow(`Ignoring --size "${sizeFlag}" — expected WxH, e.g. 660x500. Opening at the default size.`));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const fallback = await watchFallback(cfg);
|
|
582
|
+
const routing = providerFlag || url ? null : await watchContext(cfg);
|
|
583
|
+
|
|
584
|
+
// Routing decides per game, so it needs the game before anything opens.
|
|
585
|
+
let scope: Awaited<ReturnType<typeof watchScope>> | undefined;
|
|
586
|
+
let routed: Game | null = null;
|
|
587
|
+
let key = providerFlag ?? fallback;
|
|
588
|
+
if (routing) {
|
|
589
|
+
scope = await watchScope(cfg, args);
|
|
590
|
+
routed = scope && watchTarget(await scopedSeasons(cfg, scope.ids), Date.now());
|
|
591
|
+
if (routed) {
|
|
592
|
+
console.log(describeWatchGame(routed));
|
|
593
|
+
key = routeWatch(routed, routing, fallback);
|
|
594
|
+
if (!key) return;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!key) {
|
|
599
|
+
console.error(c.red(`No streaming provider for ${cfg.label}. Pass --provider, or set streamProviders.${cfg.sport} in the config.`));
|
|
600
|
+
if (!routing) console.error(c.dim(SUBSCRIPTIONS_HINT));
|
|
601
|
+
process.exitCode = 1;
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
let pick = pickOrReport(key, cfg.sport);
|
|
605
|
+
if (!pick) return;
|
|
606
|
+
const overlay = args.includes("--overlay");
|
|
607
|
+
|
|
608
|
+
if (args.includes("--smoke")) {
|
|
609
|
+
const target = url ?? pick.hub;
|
|
610
|
+
return overlay ? smokeLeagueOverlay(cfg, target, pick.label, windowSize) : smokeWatch(target, pick.label, windowSize);
|
|
611
|
+
}
|
|
612
|
+
// watch blocks until the window is closed; with no TTY nothing ever closes it.
|
|
613
|
+
if (process.stdin.isTTY !== true) {
|
|
614
|
+
console.error(c.yellow("`watch` is interactive — it opens a stream window and blocks until you close it."));
|
|
615
|
+
console.error(c.dim(`Run it in a terminal, or use \`sportsing ${cfg.sport} watch --smoke\` to just confirm the window opens.`));
|
|
616
|
+
process.exitCode = 1;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (scope === undefined) scope = await watchScope(cfg, args);
|
|
621
|
+
if (!scope) {
|
|
622
|
+
console.error(c.red(`Usage: sportsing ${cfg.sport} watch <team> [--wait] [--overlay] [--provider ${pick.key}] [--url <link>] [--smoke]`));
|
|
623
|
+
console.error(c.dim(`Or add a favorite (sportsing ${cfg.sport} fav add ${EXAMPLE_TEAM[cfg.sport] ?? "<team>"}) and omit the team.`));
|
|
624
|
+
process.exitCode = 1;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
let g: Game | null = routed;
|
|
629
|
+
if (args.includes("--wait")) {
|
|
630
|
+
const live = await waitForLeagueLive(cfg, scope.ids, scope.label);
|
|
631
|
+
g = live;
|
|
632
|
+
// A different game than the one routed above (or none was): route this one.
|
|
633
|
+
if (routing && live.id !== routed?.id) {
|
|
634
|
+
const liveKey = routeWatch(live, routing, fallback);
|
|
635
|
+
if (!liveKey) return;
|
|
636
|
+
if (liveKey !== pick.key && !(pick = pickOrReport(liveKey, cfg.sport))) return;
|
|
637
|
+
}
|
|
638
|
+
} else if (!routed) {
|
|
639
|
+
g = watchTarget(await scopedSeasons(cfg, scope.ids), Date.now());
|
|
640
|
+
console.log(g ? describeWatchGame(g) : c.dim(`No upcoming ${cfg.label} games for ${scope.label}.`));
|
|
641
|
+
}
|
|
642
|
+
// No per-game deep link (yet): open the hub and pick the game's tile.
|
|
643
|
+
if (!url) console.log(c.dim(`Opening ${pick.label}'s ${cfg.label} hub — pick the game there (use --url for a direct link).`));
|
|
644
|
+
if (overlay) {
|
|
645
|
+
// Yesterday + today: a late game started yesterday can still be on.
|
|
646
|
+
const gamesNow = () => gamesOnDays(cfg, addDays(new Date(), -1), 2);
|
|
647
|
+
return runLeagueOverlay(cfg, { url: url ?? pick.hub, provider: pick, game: g, gamesNow, windowSize });
|
|
648
|
+
}
|
|
649
|
+
await launchStream(url ?? pick.hub, pick.label, { windowSize, icon: cfg.icon });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Favorite-team alert feed for the league (see league-alerts.ts), with the
|
|
653
|
+
* favorites' names; null when the league has no (resolvable) favorites. */
|
|
654
|
+
async function alertFeed(cfg: LeagueConfig): Promise<{ teams: string[]; feed: (games: Game[]) => Alert[] } | null> {
|
|
655
|
+
const sport = cfg.sport;
|
|
656
|
+
if (!isGameSport(sport)) throw new Error(`No live alert rules for ${cfg.label}.`);
|
|
657
|
+
const favs = await getFavorites(sport);
|
|
658
|
+
if (favs.length === 0) return null;
|
|
659
|
+
const teams = await getTeams(cfg.league);
|
|
660
|
+
const favIds = favoriteIds(cfg, teams, favs);
|
|
661
|
+
if (favIds.size === 0) return null;
|
|
662
|
+
const [watch, fallback] = await Promise.all([watchContext(cfg), watchFallback(cfg)]);
|
|
663
|
+
return {
|
|
664
|
+
teams: teams.filter((t) => favIds.has(t.id)).map((t) => t.name),
|
|
665
|
+
feed: leagueFeed({ league: { sport, icon: cfg.icon, periods: cfg.periods }, favIds, watch, fallback, exe: selfInvocation() }),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The league's alerter for bare `sportsing live --notify`; null with no favorites. */
|
|
670
|
+
export async function leagueAlerter(cfg: LeagueConfig): Promise<Alerter | null> {
|
|
671
|
+
const alerts = await alertFeed(cfg);
|
|
672
|
+
if (!alerts) return null;
|
|
673
|
+
return { label: cfg.label, teams: alerts.teams, poll: async () => alerts.feed(await liveGames(cfg)) };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Full-screen live board: in play, later today, finished today. */
|
|
677
|
+
function renderLiveBoard(cfg: LeagueConfig, games: Game[]): void {
|
|
678
|
+
const { live, upcoming, done } = liveBoard(games, ymd(new Date()));
|
|
679
|
+
const line = (g: Game) => " " + gameLine(g, cfg.periods);
|
|
680
|
+
process.stdout.write("\x1b[2J\x1b[H"); // clear + home
|
|
681
|
+
console.log(c.bold(c.cyan(`${cfg.icon} ${cfg.label} — LIVE`)) + c.dim(` ${new Date().toLocaleTimeString()}`));
|
|
682
|
+
console.log(c.dim(`Refreshing every ${LIVE_REFRESH_MS / 1000}s · Ctrl-C to quit\n`));
|
|
683
|
+
if (live.length) {
|
|
684
|
+
console.log(c.bold(c.green("● LIVE NOW")));
|
|
685
|
+
for (const g of live) console.log(line(g));
|
|
686
|
+
console.log();
|
|
687
|
+
} else {
|
|
688
|
+
console.log(c.dim("No games in play right now.\n"));
|
|
689
|
+
}
|
|
690
|
+
if (upcoming.length) {
|
|
691
|
+
console.log(c.bold("Later today"));
|
|
692
|
+
for (const g of upcoming.slice(0, 8)) console.log(line(g));
|
|
693
|
+
if (upcoming.length > 8) console.log(c.dim(` … and ${upcoming.length - 8} more`));
|
|
694
|
+
console.log();
|
|
695
|
+
}
|
|
696
|
+
if (done.length) {
|
|
697
|
+
console.log(c.bold(c.dim("Finished today")));
|
|
698
|
+
for (const g of done) console.log(line(g));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* `live [--notify [--quiet]]` — auto-refreshing live board. `--notify` raises
|
|
704
|
+
* OS notifications for favorites' games (game-events.ts rules: NHL puck drop,
|
|
705
|
+
* goals, period ends, OT/shootout, final; NBA tip-off, lead changes, close
|
|
706
|
+
* late, final); start alerts are click-to-watch. `--quiet` drops the board so
|
|
707
|
+
* it can run backgrounded as a pure alerter.
|
|
708
|
+
*/
|
|
709
|
+
async function live(cfg: LeagueConfig, args: string[]): Promise<void> {
|
|
710
|
+
const wantNotify = args.includes("--notify");
|
|
711
|
+
const wantQuiet = args.includes("--quiet");
|
|
712
|
+
if (wantQuiet && !wantNotify) {
|
|
713
|
+
console.error(c.yellow("--quiet only makes sense with --notify (it hides the live view)."));
|
|
714
|
+
console.error(c.dim(`Try: sportsing ${cfg.sport} live --notify --quiet &`));
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const alerts = wantNotify ? await alertFeed(cfg) : null;
|
|
719
|
+
// Everything below goes to stderr so --quiet keeps stdout clean for backgrounding.
|
|
720
|
+
if (wantNotify && !alerts) {
|
|
721
|
+
console.error(c.yellow(`--notify is on but you have no ${cfg.label} favorite teams — you won't get alerts.`));
|
|
722
|
+
console.error(c.dim("Add one with ") + c.bold(`sportsing ${cfg.sport} fav add ${EXAMPLE_TEAM[cfg.sport] ?? "<team>"}`) + c.dim(" to get alerts."));
|
|
723
|
+
console.error("");
|
|
724
|
+
}
|
|
725
|
+
if (wantQuiet) {
|
|
726
|
+
const who = alerts ? alerts.teams.join(", ") : "no favorites set";
|
|
727
|
+
console.error(c.dim(`${cfg.label} favorite-team alerts running (${who}) — Ctrl-C to stop.`));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const tick = async () => {
|
|
731
|
+
const games = await liveGames(cfg);
|
|
732
|
+
if (!wantQuiet) renderLiveBoard(cfg, games);
|
|
733
|
+
// The first tick only sets the baseline: a game already under way raises no start alert.
|
|
734
|
+
if (alerts) for (const a of alerts.feed(games)) raise(a);
|
|
735
|
+
};
|
|
736
|
+
await tick();
|
|
737
|
+
const interval = setInterval(() => tick().catch((e) => console.error(c.red(String(e)))), LIVE_REFRESH_MS);
|
|
738
|
+
process.on("SIGINT", () => {
|
|
739
|
+
clearInterval(interval);
|
|
740
|
+
process.stdout.write("\n");
|
|
741
|
+
process.exit(0);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** The league's data as the AI commands (league-ai.ts) consume it. */
|
|
746
|
+
function leagueGames(cfg: LeagueConfig): LeagueGames {
|
|
747
|
+
return {
|
|
748
|
+
team: async (input) => resolveTeam(await getTeams(cfg.league), input, cfg.aliases),
|
|
749
|
+
season: (teamId) => teamSeason(cfg, teamId),
|
|
750
|
+
summary: (gameId) => getGameSummary(cfg.league, gameId),
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const COMMANDS: Record<string, (cfg: LeagueConfig, args: string[]) => Promise<void>> = {
|
|
755
|
+
today,
|
|
756
|
+
next,
|
|
757
|
+
schedule,
|
|
758
|
+
results,
|
|
759
|
+
standings,
|
|
760
|
+
season,
|
|
761
|
+
bracket: (cfg, args) => leagueBracket(cfg, args, favoriteIds),
|
|
762
|
+
fav: leagueFav,
|
|
763
|
+
watch,
|
|
764
|
+
live,
|
|
765
|
+
analyze: (cfg, args) => leagueAnalyze(cfg, leagueGames(cfg), args),
|
|
766
|
+
predict: (cfg, args) => leaguePredict(cfg, leagueGames(cfg), args),
|
|
767
|
+
recap: (cfg, args) => leagueRecap(cfg, leagueGames(cfg), args),
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const ALIASES: Record<string, string> = { t: "today", n: "next", st: "standings" };
|
|
771
|
+
|
|
772
|
+
/** True if `sportsing <league> <cmd>` is a command (or alias). */
|
|
773
|
+
export function leagueHasCommand(cmd: string): boolean {
|
|
774
|
+
return Object.hasOwn(COMMANDS, cmd) || Object.hasOwn(ALIASES, cmd);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function help(cfg: LeagueConfig): void {
|
|
778
|
+
const b = c.bold;
|
|
779
|
+
const s = cfg.sport;
|
|
780
|
+
const ex = EXAMPLE_TEAM[s] ?? "<team>";
|
|
781
|
+
console.log(`${b(c.cyan(`${cfg.icon} sportsing ${s}`))} — ${cfg.label}, preseason through playoffs
|
|
782
|
+
|
|
783
|
+
${b("USAGE")}
|
|
784
|
+
sportsing ${s} <command> [options]
|
|
785
|
+
|
|
786
|
+
${b("COMMANDS")}
|
|
787
|
+
${c.green("today")} Games today ${c.dim("(--tomorrow, --yesterday, --offset N)")}
|
|
788
|
+
${c.green("next")} Next game + countdown
|
|
789
|
+
${c.green("schedule")} A team's whole season, by day ${c.dim(`(league-wide: next ${WINDOW_DAYS} days)`)}
|
|
790
|
+
${c.green("results")} Finished games, newest first ${c.dim(`(league-wide: last ${WINDOW_DAYS} days)`)}
|
|
791
|
+
${c.green("standings")} Regular-season standings, favorites ★ ${c.dim("(--conference X, --division X)")}
|
|
792
|
+
${c.green("season")} ${c.dim("[team]")} Favorites' season: record, splits, playoff race
|
|
793
|
+
${c.green("bracket")} Playoff bracket ${c.dim("(projected before the postseason; --season YYYY for a past one)")}
|
|
794
|
+
${c.green("fav")} ${c.dim("[add|rm|list]")} Manage favorite teams
|
|
795
|
+
${c.green("analyze")} ${c.dim("<team> [team]")} AI read of the live or latest game ${c.dim("(--prompt)")}
|
|
796
|
+
${c.green("predict")} ${c.dim("<team> [team]")} AI prediction for the next game ${c.dim("(--prompt)")}
|
|
797
|
+
${c.green("recap")} ${c.dim("<team> [team]")} AI "here's what you missed" ${c.dim("(--prompt)")}
|
|
798
|
+
${c.green("watch")} ${c.dim("[team]")} Open the stream where you can watch it ${c.dim("(--wait, --overlay, --provider, --url, --smoke)")}
|
|
799
|
+
${c.green("live")} Auto-refreshing live board ${c.dim("(--notify: favorites' alerts; --quiet: alerts only)")}
|
|
800
|
+
|
|
801
|
+
${b("FILTER")}
|
|
802
|
+
${c.dim("--team X")} on today/next/schedule/results picks one team (abbreviation or name).
|
|
803
|
+
${c.dim("--mine")} on today/next/schedule/results limits output to your ${cfg.label} favorites.
|
|
804
|
+
${c.dim("--conference X")} / ${c.dim("--division X")} on standings shows one table (e.g. West, Pacific).
|
|
805
|
+
|
|
806
|
+
${b("TAGS")}
|
|
807
|
+
${c.yellow("PRE")} preseason · ${c.magenta("POST")} postseason. Times are local.
|
|
808
|
+
WATCH: the service or channel · ${c.red("✗")} can't watch · ${c.dim("?")} unknown ${c.dim("(from `sportsing subscriptions`)")}
|
|
809
|
+
|
|
810
|
+
${b("DATA")}
|
|
811
|
+
ESPN's free (unofficial) API — no key needed. AI commands are answered by a
|
|
812
|
+
Claude agent you keep serving (${c.dim("/loop sportsing fifa serve")} — one loop answers
|
|
813
|
+
every sport); --prompt prints the prompt instead.
|
|
814
|
+
|
|
815
|
+
${b("EXAMPLES")}
|
|
816
|
+
sportsing ${s} today
|
|
817
|
+
sportsing ${s} fav add ${ex}
|
|
818
|
+
sportsing ${s} schedule --team ${ex}
|
|
819
|
+
sportsing ${s} results --mine
|
|
820
|
+
sportsing ${s} standings --conference West
|
|
821
|
+
sportsing ${s} recap ${ex} --prompt
|
|
822
|
+
`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Build a `sportsing <sport> <command>` dispatcher for a league. */
|
|
826
|
+
export function leagueNamespace(cfg: LeagueConfig): (args: string[]) => Promise<void> {
|
|
827
|
+
return async (args) => {
|
|
828
|
+
const [cmd, ...rest] = args;
|
|
829
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return help(cfg);
|
|
830
|
+
const run = COMMANDS[cmd] ?? COMMANDS[ALIASES[cmd] ?? ""];
|
|
831
|
+
if (!run) {
|
|
832
|
+
console.error(c.red(`Unknown ${cfg.sport} command: ${cmd}`));
|
|
833
|
+
console.error(c.dim(`Run \`sportsing ${cfg.sport} help\` for usage.`));
|
|
834
|
+
process.exitCode = 1;
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
await run(cfg, rest);
|
|
838
|
+
};
|
|
839
|
+
}
|