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.
Files changed (43) hide show
  1. package/README.md +160 -12
  2. package/package.json +6 -2
  3. package/src/alerts.ts +47 -0
  4. package/src/api.ts +1 -1
  5. package/src/click-to-watch.ts +39 -0
  6. package/src/commands/_lib.ts +28 -12
  7. package/src/commands/analyze.ts +2 -2
  8. package/src/commands/ask.ts +1 -1
  9. package/src/commands/cross.ts +354 -0
  10. package/src/commands/fav.ts +19 -16
  11. package/src/commands/league-ai.ts +201 -0
  12. package/src/commands/league-bracket.ts +150 -0
  13. package/src/commands/league.ts +839 -0
  14. package/src/commands/live.ts +57 -42
  15. package/src/commands/me.ts +1 -1
  16. package/src/commands/predict.ts +2 -2
  17. package/src/commands/scorers.ts +1 -1
  18. package/src/commands/setup.ts +3 -3
  19. package/src/commands/subscriptions.ts +66 -0
  20. package/src/commands/table.ts +1 -1
  21. package/src/commands/watch.ts +19 -11
  22. package/src/config.ts +166 -28
  23. package/src/espn.ts +606 -33
  24. package/src/format.ts +87 -0
  25. package/src/game-events.ts +239 -0
  26. package/src/game.ts +51 -0
  27. package/src/index.ts +35 -16
  28. package/src/league-ai.ts +359 -0
  29. package/src/league-alerts.ts +139 -0
  30. package/src/league-detect.ts +104 -0
  31. package/src/league-overlay.ts +351 -0
  32. package/src/league-panels.ts +159 -0
  33. package/src/overlay.ts +96 -51
  34. package/src/playoff-bracket.ts +384 -0
  35. package/src/recap.ts +37 -8
  36. package/src/season.ts +182 -0
  37. package/src/sports/fifa.ts +9 -4
  38. package/src/sports/nba.ts +20 -0
  39. package/src/sports/nhl.ts +20 -0
  40. package/src/standings.ts +130 -0
  41. package/src/stream.ts +49 -8
  42. package/src/watch-route.ts +95 -0
  43. package/src/watchability.ts +183 -0
@@ -0,0 +1,95 @@
1
+ // Where-to-watch for the ESPN-backed leagues, on top of the pure resolver in
2
+ // watchability.ts: the WATCH column in today/next/schedule, and the decision
3
+ // `<sport> watch` acts on (open a provider, name an over-the-air channel, or
4
+ // explain why there's nothing to open).
5
+ //
6
+ // Pure: no IO, no config reads. Callers load subscriptions + home market from
7
+ // config.ts and pass them in as a WatchContext.
8
+
9
+ import { c, pad, visibleLen } from "./ansi.ts";
10
+ import type { Game } from "./game.ts";
11
+ import { resolveWatch, type Subscription, type Watchability, type WatchSport } from "./watchability.ts";
12
+
13
+ export function isWatchSport(sport: string): sport is WatchSport {
14
+ return sport === "nba" || sport === "nhl";
15
+ }
16
+
17
+ /** What the resolver needs besides the game. */
18
+ export interface WatchContext {
19
+ sport: WatchSport;
20
+ subscriptions: readonly Subscription[];
21
+ homeMarket: string;
22
+ }
23
+
24
+ /** Shown in place of the WATCH column when no subscriptions are configured. */
25
+ export const SUBSCRIPTIONS_HINT = "Where to watch: run `sportsing subscriptions set fubo nba-league-pass local-ota` (whichever you have).";
26
+
27
+ export function watchOf(g: Game, ctx: WatchContext): Watchability {
28
+ return resolveWatch(g, ctx.subscriptions, ctx.homeMarket, ctx.sport);
29
+ }
30
+
31
+ /**
32
+ * The WATCH cell for a game: the service or over-the-air channel, `✗` when it
33
+ * can't be watched, `?` when it can't be told. Finished games get "" — there's
34
+ * nothing left to watch live.
35
+ */
36
+ export function watchCell(g: Game, ctx: WatchContext): string {
37
+ if (g.state === "post") return "";
38
+ const w = watchOf(g, ctx);
39
+ if (w.watchable === true) return c.green(w.via ?? "?");
40
+ return w.watchable === false ? c.red("✗") : c.dim("?");
41
+ }
42
+
43
+ /**
44
+ * Append `cells` to `lines` as one aligned column (lines padded to the widest
45
+ * visible width, then two spaces). Lines with an empty cell are left as-is.
46
+ * Returns the column's start offset for a heading — null, with the lines
47
+ * untouched, when every cell is empty (e.g. only finished games).
48
+ */
49
+ export function withWatchColumn(lines: string[], cells: string[]): { lines: string[]; column: number | null } {
50
+ if (!cells.some(Boolean)) return { lines, column: null };
51
+ const column = Math.max(0, ...lines.map(visibleLen)) + 2;
52
+ return { lines: lines.map((l, i) => (cells[i] ? pad(l, column) + cells[i] : l)), column };
53
+ }
54
+
55
+ /** `text` padded out to the WATCH column, then the column heading. */
56
+ export function watchHeading(text: string, column: number): string {
57
+ return pad(text, column) + c.dim("WATCH");
58
+ }
59
+
60
+ /** One-line explanation for a single game, e.g. "Fubo — on KJZZ-TV",
61
+ * "Utah 16 — over the air", "✗ ESPN+ exclusive — no subscription". */
62
+ export function watchSummary(w: Watchability): string {
63
+ if (w.watchable === true) return w.service === "local-ota" ? w.note : `${w.via} — ${w.note}`;
64
+ return `${w.watchable === false ? "✗" : "?"} ${w.note}`;
65
+ }
66
+
67
+ /** Streaming-provider key (stream.ts PROVIDERS) for each openable subscription. */
68
+ export const SERVICE_PROVIDER: Record<Exclude<Subscription, "local-ota">, string> = {
69
+ fubo: "fubo",
70
+ "nba-league-pass": "nba-league-pass",
71
+ };
72
+
73
+ /** What `watch` does with a game. */
74
+ export type WatchPlan =
75
+ /** Open `provider`'s hub; `note` says why that one. */
76
+ | { kind: "open"; provider: string; note: string }
77
+ /** Over the air only — nothing to open; tell the user the channel. */
78
+ | { kind: "tune"; message: string }
79
+ /** Can't be watched (or can't be told and there's no provider to try). */
80
+ | { kind: "none"; message: string };
81
+
82
+ /**
83
+ * Turn a resolved Watchability into the `watch` action. A game the resolver
84
+ * can't place (`unknown` — e.g. no broadcasts listed yet) falls back to
85
+ * `fallback` (the configured / league-default provider) when there is one:
86
+ * opening the usual hub beats refusing on missing data.
87
+ */
88
+ export function planWatch(w: Watchability, fallback: string | null): WatchPlan {
89
+ const service = w.watchable === true ? w.service : null;
90
+ if (service === "local-ota") return { kind: "tune", message: w.note };
91
+ if (service) return { kind: "open", provider: SERVICE_PROVIDER[service], note: watchSummary(w) };
92
+ if (w.watchable === false) return { kind: "none", message: w.note };
93
+ if (fallback) return { kind: "open", provider: fallback, note: `can't tell where it airs (${w.note}) — trying ${fallback}` };
94
+ return { kind: "none", message: `Can't tell where it airs — ${w.note}.` };
95
+ }
@@ -0,0 +1,183 @@
1
+ // "Can I watch it?" — resolve a game's broadcasts against the user's
2
+ // subscriptions and home market.
3
+ //
4
+ // Pure: no IO, no config reads. Callers pass the subscriptions and home market
5
+ // (from config.ts) and the sport the game belongs to. The broadcaster catalog
6
+ // below is best-effort knowledge of who carries what; anything not in it
7
+ // resolves to `watchable: "unknown"` — never a guessed yes.
8
+
9
+ import type { Broadcast, Game, GameCompetitor } from "./game.ts";
10
+
11
+ /** Services / reception the user can have. Order is the routing preference:
12
+ * an openable service beats a channel you have to tune an antenna to. */
13
+ export const SUBSCRIPTIONS = ["fubo", "nba-league-pass", "local-ota"] as const;
14
+ export type Subscription = (typeof SUBSCRIPTIONS)[number];
15
+
16
+ export const SUBSCRIPTION_LABELS: Record<Subscription, string> = {
17
+ fubo: "Fubo",
18
+ "nba-league-pass": "NBA League Pass",
19
+ "local-ota": "over the air",
20
+ };
21
+
22
+ export function isSubscription(s: string): s is Subscription {
23
+ return (SUBSCRIPTIONS as readonly string[]).includes(s);
24
+ }
25
+
26
+ /** Sports the resolver knows how to route (ESPN-backed, `Game`-shaped). */
27
+ export type WatchSport = "nba" | "nhl";
28
+
29
+ export interface HomeMarket {
30
+ name: string;
31
+ /** ESPN team ids of this market's local teams, per sport. Ids, not
32
+ * abbreviations: the Mammoth are `UTAH` in /teams but `UTA` in schedules. */
33
+ teams: Record<WatchSport, string[]>;
34
+ /** Local-market channels (lowercased name → subscriptions that carry it).
35
+ * `[]` = known, but none of the supported subscriptions carry it. */
36
+ channels: Record<string, Subscription[]>;
37
+ }
38
+
39
+ export const HOME_MARKETS: Record<string, HomeMarket> = {
40
+ utah: {
41
+ name: "Utah",
42
+ teams: { nba: ["26"], nhl: ["129764"] },
43
+ channels: {
44
+ // Jazz: KJZZ is a broadcast station (OTA ch. 14) that Fubo carries in-market.
45
+ "kjzz-tv": ["fubo", "local-ota"],
46
+ kjzz: ["fubo", "local-ota"],
47
+ "jazz+": [], // the team's own streaming app
48
+ // Mammoth: over the air only.
49
+ "utah 16": ["local-ota"],
50
+ "mammoth+": [],
51
+ },
52
+ },
53
+ };
54
+
55
+ export const DEFAULT_HOME_MARKET = "utah";
56
+
57
+ /** National networks / services (lowercased name → carriers). Broadcast
58
+ * networks (ABC/NBC/CBS/FOX) come in over the air anywhere. */
59
+ const NATIONAL: Record<string, Subscription[]> = {
60
+ espn: ["fubo"],
61
+ espn2: ["fubo"],
62
+ abc: ["fubo", "local-ota"],
63
+ nbc: ["fubo", "local-ota"],
64
+ cbs: ["fubo", "local-ota"],
65
+ fox: ["fubo", "local-ota"],
66
+ fs1: ["fubo"],
67
+ "nba tv": ["fubo"],
68
+ "nhl net": ["fubo"],
69
+ "nhl network": ["fubo"],
70
+ // Known, but none of the supported subscriptions carry them.
71
+ "espn+": [],
72
+ tnt: [],
73
+ trutv: [],
74
+ "hbo max": [],
75
+ max: [],
76
+ "prime video": [],
77
+ peacock: [],
78
+ "disney+": [],
79
+ hulu: [],
80
+ "apple tv": [],
81
+ };
82
+
83
+ /** ESPN lists League Pass itself as a broadcast row; it's a subscription, not
84
+ * a channel, so it's routed by the League Pass rule, not the catalog. */
85
+ const LEAGUE_PASS_ROW = "nba league pass";
86
+
87
+ export interface Watchability {
88
+ watchable: boolean | "unknown";
89
+ /** What to watch on: a service label ("Fubo", "NBA League Pass") or, for
90
+ * over-the-air, the channel name ("Utah 16"). null unless watchable. */
91
+ via: string | null;
92
+ /** The subscription that makes it watchable; null unless watchable. */
93
+ service: Subscription | null;
94
+ /** One-line human explanation (channel, blackout, exclusivity, …). */
95
+ note: string;
96
+ }
97
+
98
+ const key = (name: string) => name.trim().toLowerCase();
99
+
100
+ /**
101
+ * Resolve where (whether) `game` can be watched with `subscriptions` from
102
+ * `homeMarket` (a HOME_MARKETS id). `sport` is needed because ESPN team ids
103
+ * are per league and League Pass is NBA-only.
104
+ *
105
+ * - National broadcasts are reachable anywhere; a home/away-market broadcast
106
+ * only when that team is a home-market team (other markets' regional feeds
107
+ * are geo-locked and ignored).
108
+ * - NBA League Pass covers out-of-market games, blacked out when a home-market
109
+ * team plays or the game has any national broadcast.
110
+ * - Any relevant broadcaster not in the catalog makes the answer "unknown"
111
+ * unless a known route already works — never a false yes.
112
+ */
113
+ export function resolveWatch(
114
+ game: Game,
115
+ subscriptions: readonly Subscription[],
116
+ homeMarket: string,
117
+ sport: WatchSport,
118
+ ): Watchability {
119
+ // hasOwn: a bare lookup would accept prototype keys like "constructor".
120
+ const market = Object.hasOwn(HOME_MARKETS, homeMarket) ? HOME_MARKETS[homeMarket] : undefined;
121
+ if (!market) return unknown(`unknown home market "${homeMarket}"`);
122
+
123
+ const has = new Set(subscriptions);
124
+ const local = market.teams[sport];
125
+ const isLocal = (t: GameCompetitor) => t.id !== "" && local.includes(t.id);
126
+ const localTeam = isLocal(game.home) ? game.home : isLocal(game.away) ? game.away : null;
127
+
128
+ const listed = game.broadcasts.filter((b) => key(b.name) !== LEAGUE_PASS_ROW);
129
+ const reachable = listed.filter(
130
+ (b) => b.market === "national" || (b.market === "home" && isLocal(game.home)) || (b.market === "away" && isLocal(game.away)),
131
+ );
132
+
133
+ const routes: { service: Subscription; channel: string }[] = [];
134
+ const unrecognized: Broadcast[] = [];
135
+ const notCarried: Broadcast[] = [];
136
+ for (const b of reachable) {
137
+ const carriers = b.market === "national" ? NATIONAL[key(b.name)] : market.channels[key(b.name)];
138
+ if (!carriers) {
139
+ unrecognized.push(b);
140
+ continue;
141
+ }
142
+ const ours = carriers.filter((s) => has.has(s));
143
+ if (ours.length) for (const s of ours) routes.push({ service: s, channel: b.name });
144
+ else notCarried.push(b);
145
+ }
146
+
147
+ // League Pass: out-of-market NBA only, and only when the listing shows no
148
+ // national broadcast (national games are blacked out; unlisted can't be ruled out).
149
+ const blackedOut = sport === "nba" && has.has("nba-league-pass") && localTeam !== null;
150
+ if (
151
+ sport === "nba" &&
152
+ has.has("nba-league-pass") &&
153
+ localTeam === null &&
154
+ game.broadcasts.length > 0 &&
155
+ !listed.some((b) => b.market === "national")
156
+ ) {
157
+ routes.push({ service: "nba-league-pass", channel: SUBSCRIPTION_LABELS["nba-league-pass"] });
158
+ }
159
+
160
+ if (routes.length) {
161
+ const best = routes.sort((a, b) => SUBSCRIPTIONS.indexOf(a.service) - SUBSCRIPTIONS.indexOf(b.service))[0]!;
162
+ if (best.service === "local-ota") return { watchable: true, via: best.channel, service: best.service, note: `${best.channel} — over the air` };
163
+ if (best.service === "nba-league-pass") return { watchable: true, via: best.channel, service: best.service, note: "out-of-market on League Pass" };
164
+ return { watchable: true, via: SUBSCRIPTION_LABELS[best.service], service: best.service, note: `on ${best.channel}` };
165
+ }
166
+
167
+ if (game.broadcasts.length === 0) return unknown("no broadcasts listed yet");
168
+ if (unrecognized.length) return unknown(`unrecognized broadcaster: ${names(unrecognized)}`);
169
+ if (blackedOut) {
170
+ const elsewhere = notCarried.length ? ` (also on ${names(notCarried)} — no subscription)` : "";
171
+ return no(`League Pass blacks out in-market ${localTeam!.name} games${elsewhere}`);
172
+ }
173
+ if (notCarried.length) return no(`${names(notCarried)} exclusive — no subscription`);
174
+ // Nothing reachable: either a home-market team's local feed isn't listed
175
+ // (can't rule it out) or the game only airs on other markets' regional TV.
176
+ if (localTeam) return unknown(`no ${market.name} broadcast listed for ${localTeam.name}`);
177
+ if (listed.length === 0) return no("only on NBA League Pass — no subscription");
178
+ return no(`only on out-of-market regional TV (${names(listed)})`);
179
+ }
180
+
181
+ const names = (bs: Broadcast[]) => [...new Set(bs.map((b) => b.name))].join(" / ");
182
+ const unknown = (note: string): Watchability => ({ watchable: "unknown", via: null, service: null, note });
183
+ const no = (note: string): Watchability => ({ watchable: false, via: null, service: null, note });