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
@@ -2,14 +2,13 @@ import { c } from "../ansi.ts";
2
2
  import { getMatches, NoKeyError } from "../api.ts";
3
3
  import { matchLine, fmtTimeOnly, stageLabel } from "../format.ts";
4
4
  import { ymd, addDays, localDateOf, sortByDate } from "./_lib.ts";
5
- import { getFavorites } from "../config.ts";
5
+ import { getApiKey, getFavorites } from "../config.ts";
6
6
  import { diffEvents, type MatchEvent } from "../events.ts";
7
- import { notify } from "../notify.ts";
7
+ import { LIVE_REFRESH_MS, raise, type Alert, type Alerter } from "../alerts.ts";
8
+ import { inTerminal, selfInvocation, watchCommand } from "../click-to-watch.ts";
8
9
  import { matchHasTeam } from "../match-util.ts";
9
10
  import type { Match } from "../types.ts";
10
11
 
11
- const REFRESH_MS = 60_000; // free tier note: scores are delayed; 60s is plenty.
12
-
13
12
  export async function live(args: string[] = []) {
14
13
  const wantNotify = args.includes("--notify");
15
14
  const wantQuiet = args.includes("--quiet");
@@ -27,7 +26,7 @@ export async function live(args: string[] = []) {
27
26
  await getMatches({ status: "IN_PLAY" }, 0);
28
27
  } catch (e) {
29
28
  if (e instanceof NoKeyError) {
30
- console.error(c.yellow("Live scores need an API key. Run `sportsing setup` first."));
29
+ console.error(c.yellow("Live scores need an API key. Run `sportsing fifa setup` first."));
31
30
  return;
32
31
  }
33
32
  throw e;
@@ -35,7 +34,7 @@ export async function live(args: string[] = []) {
35
34
 
36
35
  let favorites: string[] = [];
37
36
  if (wantNotify) {
38
- favorites = await getFavorites();
37
+ favorites = await getFavorites("fifa");
39
38
  if (favorites.length === 0) {
40
39
  // --notify with no favourites would silently never alert; say so once.
41
40
  // All on stderr so --quiet keeps stdout clean for backgrounding.
@@ -52,21 +51,10 @@ export async function live(args: string[] = []) {
52
51
  console.error(c.dim(`Favorite-team alerts running (${who}) — Ctrl-C to stop.`));
53
52
  }
54
53
 
55
- // Previous tick's full snapshot of today's matches, for fav-event diffing.
56
- // diffEvents only emits transitions between prev→cur, so an event fires once
57
- // and never re-alerts on a later unchanged tick (AGT-507 idempotency).
58
- let prevSnapshot: Match[] = [];
54
+ const feed = wantNotify ? fifaFeed(favorites, selfInvocation()) : null;
59
55
 
60
56
  const tick = async () => {
61
- const now = new Date();
62
- const today = ymd(now);
63
- // Query ±1 UTC day, then keep only matches on today's local calendar date
64
- // (a local day straddles two UTC days — see today.ts).
65
- const { matches: raw } = await getMatches(
66
- { dateFrom: ymd(addDays(now, -1)), dateTo: ymd(addDays(now, 1)) },
67
- 20_000,
68
- );
69
- const matches = raw.filter((m) => localDateOf(m.utcDate) === today);
57
+ const matches = await matchesToday();
70
58
 
71
59
  // Full-screen scoreboard — skipped in --quiet so the command can be
72
60
  // backgrounded without alt-screen clears corrupting the parent shell.
@@ -102,26 +90,13 @@ export async function live(args: string[] = []) {
102
90
  }
103
91
  }
104
92
 
105
- if (wantNotify) {
106
- // Diff this snapshot against the previous tick's and alert on new fav
107
- // events. The first tick has an empty prevSnapshot, so it only establishes
108
- // a baseline (a match already in play when you start raises no kickoff).
109
- for (const e of diffEvents(prevSnapshot, matches, favorites)) {
110
- const { title, body } = formatEvent(e);
111
- // Kickoff alerts are click-to-watch: clicking launches `watch <fav team>`
112
- // (terminal-notifier -execute). notify() degrades to a plain, non-clickable
113
- // notification when terminal-notifier is absent. Goal/full-time stay informational.
114
- // process.execPath is the sportsing binary when distributed (compiled); under
115
- // `bun run` dev it's the Bun runtime, so the click command only works compiled.
116
- const onClick = kickoffWatchCommand(e, matches, favorites, process.execPath);
117
- notify(title, body, { group: `sportsing-${e.matchId}`, sound: e.kind === "goal", onClick });
118
- }
119
- prevSnapshot = matches;
120
- }
93
+ // The first tick only establishes a baseline (a match already in play when
94
+ // you start raises no kickoff).
95
+ if (feed) for (const a of feed(matches)) raise(a);
121
96
  };
122
97
 
123
98
  await tick();
124
- const interval = setInterval(() => tick().catch((e) => console.error(c.red(String(e)))), REFRESH_MS);
99
+ const interval = setInterval(() => tick().catch((e) => console.error(c.red(String(e)))), LIVE_REFRESH_MS); // free tier: scores are delayed; 60s is plenty
125
100
  process.on("SIGINT", () => {
126
101
  clearInterval(interval);
127
102
  process.stdout.write("\n");
@@ -133,14 +108,54 @@ function stageTag(m: Match): string {
133
108
  return c.dim(" " + stageLabel(m));
134
109
  }
135
110
 
136
- /** POSIX single-quote a string so it's safe as one shell argument. */
137
- function shQuote(s: string): string {
138
- return `'${s.replace(/'/g, `'\\''`)}'`;
111
+ /** Today's matches (local calendar day). football-data filters by UTC date, so
112
+ * query ±1 UTC day and keep the local day (a local day straddles two UTC days). */
113
+ async function matchesToday(): Promise<Match[]> {
114
+ const now = new Date();
115
+ const today = ymd(now);
116
+ const { matches } = await getMatches({ dateFrom: ymd(addDays(now, -1)), dateTo: ymd(addDays(now, 1)) }, 20_000);
117
+ return matches.filter((m) => localDateOf(m.utcDate) === today);
118
+ }
119
+
120
+ /**
121
+ * A stateful fav-alert feed: hand it each tick's matches and it returns the
122
+ * alerts for new favourite events since the previous tick. diffEvents only
123
+ * emits prev→cur transitions, so an event fires once and never re-alerts on a
124
+ * later unchanged tick (AGT-507 idempotency); the first tick is the baseline.
125
+ *
126
+ * Kickoff alerts are click-to-watch: clicking opens a Terminal running
127
+ * `fifa watch <fav team>` (terminal-notifier -execute; notify() drops the click
128
+ * when terminal-notifier is absent). Goal/full-time stay informational.
129
+ */
130
+ export function fifaFeed(favorites: string[], exe: string[]): (matches: Match[]) => Alert[] {
131
+ let prev: Match[] = [];
132
+ return (matches) => {
133
+ const alerts = diffEvents(prev, matches, favorites).map((e): Alert => {
134
+ const { title, body } = formatEvent(e);
135
+ const onClick = kickoffWatchCommand(e, matches, favorites, exe);
136
+ return {
137
+ title,
138
+ body,
139
+ options: { group: `sportsing-${e.matchId}`, sound: e.kind === "goal", onClick: onClick && inTerminal(onClick) },
140
+ };
141
+ });
142
+ prev = matches;
143
+ return alerts;
144
+ };
145
+ }
146
+
147
+ /** FIFA's alerter for bare `sportsing live --notify`; null with no FIFA favorites. */
148
+ export async function fifaAlerter(): Promise<Alerter | null> {
149
+ const favorites = await getFavorites("fifa");
150
+ if (favorites.length === 0) return null;
151
+ if (!(await getApiKey())) throw new Error("FIFA alerts need an API key — run `sportsing fifa setup`.");
152
+ const feed = fifaFeed(favorites, selfInvocation());
153
+ return { label: "FIFA", teams: favorites, poll: async () => feed(await matchesToday()) };
139
154
  }
140
155
 
141
156
  /**
142
157
  * The click-to-watch command for a kickoff event, or undefined when none applies.
143
- * Returns `<exe> fifa watch <fav>` (shell-quoted) where `fav` is the favourite
158
+ * Returns `<exe…> fifa watch <fav>` (shell-quoted) where `fav` is the favourite
144
159
  * term that put this match on the alert list — so clicking the kickoff alert opens
145
160
  * that team's broadcast. Only kickoff events are clickable (goal/full-time are
146
161
  * informational); returns undefined if the match or a matching favourite is gone.
@@ -149,14 +164,14 @@ export function kickoffWatchCommand(
149
164
  e: MatchEvent,
150
165
  matches: Match[],
151
166
  favorites: string[],
152
- exe: string,
167
+ exe: string[],
153
168
  ): string | undefined {
154
169
  if (e.kind !== "kickoff") return undefined;
155
170
  const match = matches.find((m) => m.id === e.matchId);
156
171
  if (!match) return undefined;
157
172
  const fav = favorites.find((f) => matchHasTeam(match, f.trim().toLowerCase()));
158
173
  if (!fav) return undefined;
159
- return `${shQuote(exe)} fifa watch ${shQuote(fav.trim())}`;
174
+ return watchCommand(exe, "fifa", fav.trim());
160
175
  }
161
176
 
162
177
  /** Notification title + body for a fav match event. */
@@ -8,7 +8,7 @@ import type { Match, StandingsTable } from "../types.ts";
8
8
  // `sportsing fifa me` — a personalized dashboard for your favorite teams:
9
9
  // last result, next match + countdown, and current group position.
10
10
  export async function me(_args: string[]): Promise<void> {
11
- const favorites = await getFavorites();
11
+ const favorites = await getFavorites("fifa");
12
12
  if (favorites.length === 0) return noFavoritesHint();
13
13
 
14
14
  const matches = await withFallback(
@@ -49,7 +49,7 @@ export async function predict(args: string[]) {
49
49
  }
50
50
 
51
51
  process.stderr.write(c.dim("Posted to the ask bus — waiting for your Claude agent to answer…\n"));
52
- process.stderr.write(c.dim("(keep one serving: /loop sportsing serve)\n"));
52
+ process.stderr.write(c.dim("(keep one serving: /loop sportsing fifa serve)\n"));
53
53
  const id = await postQuestion({
54
54
  source: "predict",
55
55
  question: prompt,
@@ -60,7 +60,7 @@ export async function predict(args: string[]) {
60
60
  const prediction = await waitForAnswer(id, 180_000);
61
61
  if (prediction === null) {
62
62
  console.error(c.yellow("No Claude agent answered within 3 minutes."));
63
- console.error(c.dim("Start a serving agent in another Claude session, then retry: /loop sportsing serve"));
63
+ console.error(c.dim("Start a serving agent in another Claude session, then retry: /loop sportsing fifa serve"));
64
64
  console.error(c.dim("Or run with --prompt to predict elsewhere."));
65
65
  process.exitCode = 1;
66
66
  return;
@@ -8,7 +8,7 @@ export async function scorers() {
8
8
  list = (await getScorers()).scorers;
9
9
  } catch (e) {
10
10
  if (e instanceof NoKeyError) {
11
- console.error(c.yellow("Top scorers need live data. Run `sportsing setup` to add a free API key."));
11
+ console.error(c.yellow("Top scorers need live data. Run `sportsing fifa setup` to add a free API key."));
12
12
  return;
13
13
  }
14
14
  throw e;
@@ -2,10 +2,10 @@ import { c } from "../ansi.ts";
2
2
  import { setApiKey, getApiKey, CONFIG_FILE } from "../config.ts";
3
3
 
4
4
  export async function setup(args: string[]) {
5
- // Non-interactive: `sportsing setup <key>`
5
+ // Non-interactive: `sportsing fifa setup <key>`
6
6
  const inline = args.find((a) => !a.startsWith("--"));
7
7
 
8
- console.log(c.bold(c.cyan("⚽ sportsing setup")));
8
+ console.log(c.bold(c.cyan("⚽ sportsing fifa setup")));
9
9
  console.log(
10
10
  "\nLive data comes from " +
11
11
  c.underline("football-data.org") +
@@ -36,5 +36,5 @@ export async function setup(args: string[]) {
36
36
  console.log(c.green("ok ✓"));
37
37
  await setApiKey(key);
38
38
  console.log(c.dim(`Saved to ${CONFIG_FILE}`));
39
- console.log("\nTry: " + c.bold("sportsing today") + " · " + c.bold("sportsing next") + " · " + c.bold("sportsing bracket"));
39
+ console.log("\nTry: " + c.bold("sportsing fifa today") + " · " + c.bold("sportsing fifa next") + " · " + c.bold("sportsing fifa bracket"));
40
40
  }
@@ -0,0 +1,66 @@
1
+ import { c } from "../ansi.ts";
2
+ import {
3
+ getSubscriptions,
4
+ setSubscriptions,
5
+ getHomeMarket,
6
+ setHomeMarket,
7
+ parseSubscriptions,
8
+ parseHomeMarket,
9
+ CONFIG_FILE,
10
+ } from "../config.ts";
11
+ import { SUBSCRIPTIONS, SUBSCRIPTION_LABELS, HOME_MARKETS } from "../watchability.ts";
12
+
13
+ // `sportsing subscriptions [set <ids…> | market <id>]` — what you can watch
14
+ // with, and where you live (for blackouts and local channels). Cross-sport:
15
+ // the same Fubo / antenna covers every league.
16
+ export async function subscriptions(args: string[]): Promise<void> {
17
+ const [sub, ...rest] = args;
18
+
19
+ if (!sub || sub === "list" || sub === "show") return show();
20
+
21
+ if (sub === "set") {
22
+ if (rest.length === 0) return usage("subscriptions set <" + SUBSCRIPTIONS.join("|") + "|none>…");
23
+ const none = rest.length === 1 && rest[0]!.trim().toLowerCase() === "none";
24
+ const { subscriptions: subs, invalid } = none ? { subscriptions: [], invalid: [] } : parseSubscriptions(rest);
25
+ if (invalid.length) {
26
+ console.error(c.red(`Unknown subscription: ${invalid.join(", ")}`) + c.dim(` (choose from ${SUBSCRIPTIONS.join(", ")})`));
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ await setSubscriptions(subs);
31
+ console.log(c.green("✓ Saved.") + c.dim(` (${CONFIG_FILE})`));
32
+ return show();
33
+ }
34
+
35
+ if (sub === "market") {
36
+ const input = rest.join(" ").trim();
37
+ if (!input) return usage("subscriptions market <" + Object.keys(HOME_MARKETS).join("|") + ">");
38
+ const market = parseHomeMarket(input);
39
+ if (!market) {
40
+ console.error(c.red(`Unknown home market: ${input}`) + c.dim(` (choose from ${Object.keys(HOME_MARKETS).join(", ")})`));
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ await setHomeMarket(market);
45
+ console.log(c.green("✓ Saved.") + c.dim(` (${CONFIG_FILE})`));
46
+ return show();
47
+ }
48
+
49
+ usage("subscriptions [set <ids…> | market <id>]");
50
+ }
51
+
52
+ async function show(): Promise<void> {
53
+ const [subs, market] = await Promise.all([getSubscriptions(), getHomeMarket()]);
54
+ console.log(c.bold(c.cyan("📺 Subscriptions")));
55
+ if (subs.length === 0) {
56
+ console.log(c.dim(" none yet — e.g. ") + c.bold("sportsing subscriptions set fubo nba-league-pass local-ota"));
57
+ } else {
58
+ for (const s of subs) console.log(` ${s} ${c.dim("— " + SUBSCRIPTION_LABELS[s])}`);
59
+ }
60
+ console.log(c.bold("Home market: ") + (HOME_MARKETS[market]?.name ?? market) + c.dim(` (${Object.keys(HOME_MARKETS).join(", ")})`));
61
+ }
62
+
63
+ function usage(form: string): void {
64
+ console.error(c.red(`Usage: sportsing ${form}`));
65
+ process.exitCode = 1;
66
+ }
@@ -11,7 +11,7 @@ export async function table(args: string[]) {
11
11
  } catch (e) {
12
12
  if (e instanceof NoKeyError) {
13
13
  console.error(
14
- c.yellow("Group tables need live data. Run `sportsing setup` to add a free API key."),
14
+ c.yellow("Group tables need live data. Run `sportsing fifa setup` to add a free API key."),
15
15
  );
16
16
  return;
17
17
  }
@@ -1,7 +1,7 @@
1
1
  import { c } from "../ansi.ts";
2
2
  import { findCurrentMatch, resolveWatchTarget, type EspnEvent } from "../espn.ts";
3
3
  import { getStreamProvider } from "../config.ts";
4
- import { PROVIDERS, launchStream, spawnStreamWindow } from "../stream.ts";
4
+ import { launchStream, pickProvider, spawnStreamWindow } from "../stream.ts";
5
5
  import { runOverlayStream, type WatchLang } from "../overlay.ts";
6
6
  import { freePort, attachToPage } from "../cdp.ts";
7
7
  import { writeWatchPidfile } from "../liveness.ts";
@@ -47,13 +47,13 @@ export async function watch(args: string[]) {
47
47
  const terms = positionalTerms(args);
48
48
 
49
49
  // Default to Fubo (English/Fox). Peacock is Spanish-only (Telemundo).
50
- const key = (providerFlag ?? (await getStreamProvider()) ?? "fubo").toLowerCase();
51
- const provider = PROVIDERS[key];
52
- if (!provider) {
53
- console.error(c.red(`Unknown provider "${key}". Known: ${Object.keys(PROVIDERS).join(", ")}.`));
50
+ const pick = pickProvider(providerFlag ?? (await getStreamProvider("fifa")) ?? "fubo", "fifa");
51
+ if (!pick.ok) {
52
+ console.error(c.red(pick.error));
54
53
  process.exitCode = 1;
55
54
  return;
56
55
  }
56
+ const provider = pick;
57
57
 
58
58
  // `watch` opens a window and BLOCKS until you close it (or Ctrl-C). With no
59
59
  // controlling TTY — an agent/build smoke-test, `< /dev/null` — nothing ever
@@ -167,15 +167,23 @@ async function waitForLive(terms: string[]): Promise<EspnEvent> {
167
167
  const ms = Date.parse(target.date) - Date.now();
168
168
  const eta = ms > 0 ? `kicks off in ${fmtEta(ms)}` : "at/just past kickoff — waiting for it to flip live";
169
169
  console.log(c.dim(` ${eta}.`));
170
- if (ms <= 5 * 60_000) pollMs = 15_000; // tighten near kickoff (and once past it)
171
- else if (ms <= 15 * 60_000) pollMs = 30_000;
170
+ pollMs = waitPollMs(ms);
172
171
  }
173
172
  await new Promise((r) => setTimeout(r, pollMs));
174
173
  }
175
174
  }
176
175
 
176
+ /** How long `watch --wait` sleeps between polls, given the ms until the target
177
+ * starts: 60s normally, tightening to 30s inside 15 minutes and 15s inside 5
178
+ * (and once past the start, while waiting for the game to flip live). */
179
+ export function waitPollMs(msToStart: number): number {
180
+ if (msToStart <= 5 * 60_000) return 15_000;
181
+ if (msToStart <= 15 * 60_000) return 30_000;
182
+ return 60_000;
183
+ }
184
+
177
185
  /** Coarse human ETA: "1h 4m", "12m 30s", or "45s". */
178
- function fmtEta(ms: number): string {
186
+ export function fmtEta(ms: number): string {
179
187
  const s = Math.max(0, Math.floor(ms / 1000));
180
188
  const h = Math.floor(s / 3600);
181
189
  const m = Math.floor((s % 3600) / 60);
@@ -189,7 +197,7 @@ function fmtEta(ms: number): string {
189
197
  * then tears it down and exits. Bounded (CDP attach has its own timeout) and
190
198
  * leaves no survivors — win.close() reaps the ui-leaf/Chrome tree.
191
199
  */
192
- async function smokeWatch(url: string, label: string, windowSize: { width: number; height: number } | undefined): Promise<void> {
200
+ export async function smokeWatch(url: string, label: string, windowSize: { width: number; height: number } | undefined): Promise<void> {
193
201
  const port = await freePort();
194
202
  const win = await spawnStreamWindow(url, label, { debugPort: port, windowSize });
195
203
  if (!win) {
@@ -209,13 +217,13 @@ async function smokeWatch(url: string, label: string, windowSize: { width: numbe
209
217
  }
210
218
 
211
219
  /** Parse a `WxH` size string into a window size, or undefined if absent/invalid. */
212
- function parseSize(s: string | null): { width: number; height: number } | undefined {
220
+ export function parseSize(s: string | null): { width: number; height: number } | undefined {
213
221
  const m = s?.match(/^(\d+)x(\d+)$/);
214
222
  return m ? { width: Number(m[1]), height: Number(m[2]) } : undefined;
215
223
  }
216
224
 
217
225
  /** Positional args, dropping flags and the values consumed by --url / --provider / --size. */
218
- function positionalTerms(args: string[]): string[] {
226
+ export function positionalTerms(args: string[]): string[] {
219
227
  const out: string[] = [];
220
228
  for (let i = 0; i < args.length; i++) {
221
229
  const a = args[i]!;
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { mkdir, chmod } from "fs/promises";
4
+ import { SUBSCRIPTIONS, HOME_MARKETS, DEFAULT_HOME_MARKET, isSubscription, type Subscription } from "./watchability.ts";
4
5
 
5
6
  const CONFIG_DIR = join(homedir(), ".config", "sportsing");
6
7
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -13,13 +14,24 @@ const LEGACY_CONFIG_FILE = join(homedir(), ".config", "sportsball", "config.json
13
14
 
14
15
  interface Config {
15
16
  apiKey?: string;
17
+ /** Sport-scoped favorites, `<sport>:<team>` (e.g. `nba:UTAH`, `fifa:USA`).
18
+ * Legacy unprefixed entries (pre-NBA/NHL) read as `fifa:<team>`. */
16
19
  favorites?: string[];
17
- /** Preferred streaming provider for `fifa watch` (peacock | fubo). */
20
+ /** Preferred streaming provider for `fifa watch` (peacock | fubo). Predates
21
+ * per-sport providers, so it only ever applies to fifa. */
18
22
  streamProvider?: string;
23
+ /** Preferred streaming provider per sport for `<sport> watch`, e.g.
24
+ * `{ "nba": "fubo" }`. Overrides the sport's built-in default. */
25
+ streamProviders?: Record<string, string>;
19
26
  /** Calibrated overlay delay (seconds) per provider, to sync stats to the stream. */
20
27
  streamDelay?: Record<string, number>;
21
- /** Overlay panel choices (the gear/settings) — per provider → { panel: on }. */
28
+ /** Overlay panel choices (the gear/settings) — per provider (FIFA) or
29
+ * provider:sport (e.g. "fubo:nba") → { panel: on }. */
22
30
  overlayPanels?: Record<string, Record<string, boolean>>;
31
+ /** What the user can watch with (see SUBSCRIPTIONS in watchability.ts). */
32
+ subscriptions?: string[];
33
+ /** HOME_MARKETS id for blackouts / local channels; defaults to Utah. */
34
+ homeMarket?: string;
23
35
  }
24
36
 
25
37
  /** Default overlay panel visibility — nothing on by default, so a fresh stream
@@ -71,10 +83,18 @@ export async function setApiKey(key: string): Promise<void> {
71
83
  await writeConfig(cfg);
72
84
  }
73
85
 
74
- /** Preferred streaming provider for `fifa watch`, or null if unset. */
75
- export async function getStreamProvider(): Promise<string | null> {
76
- const cfg = await readConfig();
77
- return cfg.streamProvider?.trim().toLowerCase() || null;
86
+ /** A sport's configured streaming provider (lowercased), or null if unset:
87
+ * `streamProviders[sport]`, falling back to the legacy `streamProvider` for
88
+ * fifa only. Pure over the parsed config. */
89
+ export function streamProviderFor(cfg: Pick<Config, "streamProvider" | "streamProviders">, sport: string): string | null {
90
+ const s = sport.trim().toLowerCase();
91
+ const v = cfg.streamProviders?.[s] ?? (s === "fifa" ? cfg.streamProvider : undefined);
92
+ return v?.trim().toLowerCase() || null;
93
+ }
94
+
95
+ /** Preferred streaming provider for `<sport> watch`, or null if unset. */
96
+ export async function getStreamProvider(sport: string): Promise<string | null> {
97
+ return streamProviderFor(await readConfig(), sport);
78
98
  }
79
99
 
80
100
  export async function setStreamProvider(provider: string): Promise<void> {
@@ -96,11 +116,16 @@ export async function setStreamDelay(provider: string, seconds: number): Promise
96
116
  await writeConfig(cfg);
97
117
  }
98
118
 
99
- /** Overlay panel visibility for a provider (defaults merged with saved choices). */
100
- export async function getOverlayPanels(provider: string): Promise<Record<string, boolean>> {
119
+ /** Overlay panel visibility for a provider (defaults merged with saved choices).
120
+ * `provider` is the overlayPanels key: the provider (FIFA, e.g. "fubo") or
121
+ * provider:sport for the league overlays (e.g. "fubo:nba"), whose panels differ. */
122
+ export async function getOverlayPanels(
123
+ provider: string,
124
+ defaults: Record<string, boolean> = OVERLAY_PANEL_DEFAULTS,
125
+ ): Promise<Record<string, boolean>> {
101
126
  const cfg = await readConfig();
102
127
  const saved = cfg.overlayPanels?.[provider.trim().toLowerCase()] ?? {};
103
- return { ...OVERLAY_PANEL_DEFAULTS, ...saved };
128
+ return { ...defaults, ...saved };
104
129
  }
105
130
 
106
131
  export async function setOverlayPanel(provider: string, key: string, on: boolean): Promise<void> {
@@ -111,34 +136,147 @@ export async function setOverlayPanel(provider: string, key: string, on: boolean
111
136
  await writeConfig(cfg);
112
137
  }
113
138
 
114
- /** Favorite teams, in the order they were added (as the user typed them). */
115
- export async function getFavorites(): Promise<string[]> {
116
- const cfg = await readConfig();
117
- return cfg.favorites ?? [];
139
+ // ── Favorites ────────────────────────────────────────────────────────────────
140
+ // Stored as `<sport>:<team>` because team abbreviations collide across sports
141
+ // (the Jazz and the Mammoth are both ESPN `UTAH`). Entries written before
142
+ // sports were scoped are bare team names and are read as `fifa:<team>`; the
143
+ // next write rewrites them in prefixed form.
144
+
145
+ /** The sport a legacy (unprefixed) favorite belongs to. */
146
+ export const LEGACY_FAVORITE_SPORT = "fifa";
147
+
148
+ /** A stored favorite entry, split into its sport and team. */
149
+ export interface Favorite {
150
+ sport: string;
151
+ team: string;
152
+ }
153
+
154
+ /** Sport prefixes are short lowercase ids (`fifa`, `nba`, `nhl`). */
155
+ const PREFIXED = /^([a-z][a-z0-9]*):(.+)$/;
156
+
157
+ /** Parse a stored entry; unprefixed (legacy) entries are FIFA teams. */
158
+ export function parseFavorite(entry: string): Favorite {
159
+ const m = PREFIXED.exec(entry.trim());
160
+ if (m) return { sport: m[1]!, team: m[2]!.trim() };
161
+ return { sport: LEGACY_FAVORITE_SPORT, team: entry.trim() };
162
+ }
163
+
164
+ /** Serialize a favorite to its stored `<sport>:<team>` form. */
165
+ export function formatFavorite(f: Favorite): string {
166
+ return `${f.sport}:${f.team}`;
118
167
  }
119
168
 
120
- /** Add a favorite team. No-op (added=false) if an equal name already exists. */
121
- export async function addFavorite(team: string): Promise<{ added: boolean; favorites: string[] }> {
169
+ const normSport = (sport: string) => sport.trim().toLowerCase();
170
+ const sameTeam = (a: string, b: string) => a.toLowerCase() === b.trim().toLowerCase();
171
+
172
+ // Pure helpers over the stored entry list (unit-tested without touching disk).
173
+
174
+ /** A sport's team names from stored entries, in insertion order. */
175
+ export function favoritesFor(entries: string[], sport: string): string[] {
176
+ const s = normSport(sport);
177
+ return entries
178
+ .filter((e) => e.trim())
179
+ .map(parseFavorite)
180
+ .filter((f) => f.sport === s)
181
+ .map((f) => f.team);
182
+ }
183
+
184
+ /** Stored entries with `team` added for `sport` (unless an equal name exists in
185
+ * that sport, case-insensitive). Output is fully prefixed — legacy migrates. */
186
+ export function withFavorite(entries: string[], sport: string, team: string): { added: boolean; entries: string[] } {
187
+ const s = normSport(sport);
122
188
  const name = team.trim();
189
+ const all = entries.filter((e) => e.trim()).map(parseFavorite);
190
+ const exists = all.some((f) => f.sport === s && sameTeam(f.team, name));
191
+ if (!exists) all.push({ sport: s, team: name });
192
+ return { added: !exists, entries: all.map(formatFavorite) };
193
+ }
194
+
195
+ /** Stored entries with `sport`'s `team` removed (case-insensitive); other
196
+ * sports are untouched. Output is fully prefixed — legacy migrates. */
197
+ export function withoutFavorite(entries: string[], sport: string, team: string): { removed: boolean; entries: string[] } {
198
+ const s = normSport(sport);
199
+ const all = entries.filter((e) => e.trim()).map(parseFavorite);
200
+ const i = all.findIndex((f) => f.sport === s && sameTeam(f.team, team));
201
+ if (i >= 0) all.splice(i, 1);
202
+ return { removed: i >= 0, entries: all.map(formatFavorite) };
203
+ }
204
+
205
+ /** A sport's favorite team names, in the order they were added (as typed). */
206
+ export async function getFavorites(sport: string): Promise<string[]> {
207
+ const cfg = await readConfig();
208
+ return favoritesFor(cfg.favorites ?? [], sport);
209
+ }
210
+
211
+ /** Add a favorite team for a sport. No-op (added=false) if that sport already
212
+ * has an equal name. Returns that sport's favorites. */
213
+ export async function addFavorite(sport: string, team: string): Promise<{ added: boolean; favorites: string[] }> {
123
214
  const cfg = await readConfig();
124
- const favorites = cfg.favorites ?? [];
125
- const exists = favorites.some((f) => f.toLowerCase() === name.toLowerCase());
126
- if (!exists) favorites.push(name);
127
- cfg.favorites = favorites;
215
+ const { added, entries } = withFavorite(cfg.favorites ?? [], sport, team);
216
+ cfg.favorites = entries;
128
217
  await writeConfig(cfg);
129
- return { added: !exists, favorites };
218
+ return { added, favorites: favoritesFor(entries, sport) };
219
+ }
220
+
221
+ /** Remove a sport's favorite team (case-insensitive). removed=false if it
222
+ * wasn't there. Returns that sport's favorites. */
223
+ export async function removeFavorite(sport: string, team: string): Promise<{ removed: boolean; favorites: string[] }> {
224
+ const cfg = await readConfig();
225
+ const { removed, entries } = withoutFavorite(cfg.favorites ?? [], sport, team);
226
+ cfg.favorites = entries;
227
+ await writeConfig(cfg);
228
+ return { removed, favorites: favoritesFor(entries, sport) };
229
+ }
230
+
231
+ // ── Subscriptions + home market ──────────────────────────────────────────────
232
+ // Pure helpers over raw input / stored values (unit-tested without touching
233
+ // disk); the async get/set pair below is the IO edge.
234
+
235
+ /** Parse user input (space- and/or comma-separated ids, any case) into
236
+ * subscriptions in canonical order, deduped, plus anything unrecognized. */
237
+ export function parseSubscriptions(input: string[]): { subscriptions: Subscription[]; invalid: string[] } {
238
+ const tokens = input.flatMap((a) => a.split(",")).map((t) => t.trim().toLowerCase()).filter(Boolean);
239
+ const invalid = [...new Set(tokens.filter((t) => !isSubscription(t)))];
240
+ const subscriptions = SUBSCRIPTIONS.filter((s) => tokens.includes(s));
241
+ return { subscriptions, invalid };
242
+ }
243
+
244
+ /** Stored subscriptions, dropping anything no longer recognized. */
245
+ export function subscriptionsOf(stored: unknown): Subscription[] {
246
+ if (!Array.isArray(stored)) return [];
247
+ return parseSubscriptions(stored.filter((s): s is string => typeof s === "string")).subscriptions;
248
+ }
249
+
250
+ /** Normalize a home-market id; null if it isn't a known market. */
251
+ export function parseHomeMarket(input: string): string | null {
252
+ const id = input.trim().toLowerCase();
253
+ return Object.hasOwn(HOME_MARKETS, id) ? id : null;
254
+ }
255
+
256
+ /** Stored home market, falling back to the default for unset/unknown values. */
257
+ export function homeMarketOf(stored: unknown): string {
258
+ return (typeof stored === "string" && parseHomeMarket(stored)) || DEFAULT_HOME_MARKET;
259
+ }
260
+
261
+ export async function getSubscriptions(): Promise<Subscription[]> {
262
+ return subscriptionsOf((await readConfig()).subscriptions);
263
+ }
264
+
265
+ export async function setSubscriptions(subscriptions: Subscription[]): Promise<void> {
266
+ const cfg = await readConfig();
267
+ cfg.subscriptions = [...subscriptions];
268
+ await writeConfig(cfg);
269
+ }
270
+
271
+ export async function getHomeMarket(): Promise<string> {
272
+ return homeMarketOf((await readConfig()).homeMarket);
130
273
  }
131
274
 
132
- /** Remove a favorite team (case-insensitive). removed=false if it wasn't there. */
133
- export async function removeFavorite(team: string): Promise<{ removed: boolean; favorites: string[] }> {
275
+ /** `market` must be a known HOME_MARKETS id (see parseHomeMarket). */
276
+ export async function setHomeMarket(market: string): Promise<void> {
134
277
  const cfg = await readConfig();
135
- const favorites = cfg.favorites ?? [];
136
- const i = favorites.findIndex((f) => f.toLowerCase() === team.trim().toLowerCase());
137
- const removed = i >= 0;
138
- if (removed) favorites.splice(i, 1);
139
- cfg.favorites = favorites;
278
+ cfg.homeMarket = market;
140
279
  await writeConfig(cfg);
141
- return { removed, favorites };
142
280
  }
143
281
 
144
282
  export { CONFIG_FILE };