sportsing 0.1.2 → 0.2.1

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 (45) hide show
  1. package/README.md +167 -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/upgrade.ts +137 -0
  22. package/src/commands/watch.ts +19 -11
  23. package/src/config.ts +166 -28
  24. package/src/espn.ts +606 -33
  25. package/src/format.ts +87 -0
  26. package/src/game-events.ts +239 -0
  27. package/src/game.ts +51 -0
  28. package/src/index.ts +41 -16
  29. package/src/league-ai.ts +359 -0
  30. package/src/league-alerts.ts +139 -0
  31. package/src/league-detect.ts +104 -0
  32. package/src/league-overlay.ts +351 -0
  33. package/src/league-panels.ts +159 -0
  34. package/src/overlay.ts +96 -51
  35. package/src/playoff-bracket.ts +384 -0
  36. package/src/recap.ts +37 -8
  37. package/src/season.ts +182 -0
  38. package/src/sports/fifa.ts +9 -4
  39. package/src/sports/nba.ts +20 -0
  40. package/src/sports/nhl.ts +20 -0
  41. package/src/standings.ts +130 -0
  42. package/src/stream.ts +49 -8
  43. package/src/upgrade.ts +182 -0
  44. package/src/watch-route.ts +95 -0
  45. package/src/watchability.ts +183 -0
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 };