diff --git a/README.md b/README.md index 04e5e78..10b5b9c 100644 --- a/README.md +++ b/README.md @@ -100,14 +100,23 @@ Secrets belong on the service and in the logicsrc vault, not in a committed ## Radio (SiriusXM) -A reader connects their own SiriusXM subscription in settings — the email on the -account and the code SiriusXM sends to it, the way the SiriusXM app signs in — -and the sports and news lineups play on `/radio` and on a fixture's page. The +A reader connects their own SiriusXM subscription in settings — email and +password, or the code SiriusXM emails them, the two doors the SiriusXM app +itself offers; only the resulting session is kept — and the sports and news lineups play on `/radio` and on a fixture's page. The session is sealed with the playlist key and every byte is fetched by the server as that reader; the browser never sees a SiriusXM address or a bearer. The player is `@profullstack/player` with its audio bar, bundled to `vendor-player.js` and fetched on the first press of Play. +For the leagues SiriusXM carries by team (NFL, NBA, MLB, NHL, WNBA, the college +football and basketball conferences, MLS), a fixture's page and a team's page +draw an **On SiriusXM** section that looks up each side's own feed as soon as +the page is up: the team name is parsed (`packages/radio/src/teams.js`), +SiriusXM is searched for it on the reader's session, and only channels that +actually name the team are kept. Lookups are cached across readers for ten +minutes, so a busy fixture costs one search per side, not one per view. Team +feeds appear close to kickoff and vanish after; the section says which. + Knobs, all read at request time: - `SIRIUSXM` — `0` turns the rail off. Defaults to on for the tipoffwatch brand diff --git a/apps/web/public/app.js b/apps/web/public/app.js index c3a171c..959fec1 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -1689,17 +1689,18 @@ function initRadioSection(section) { wire(section); /* - * The event page asks only when told to. Searching SiriusXM is an upstream - * call on the reader's own session, and most visits to a fixture do not want - * radio for it. The answer arrives as HTML rendered by the same component as - * the lineup page, so there is one row template and it is on the server. + * A fixture's or a team's own feeds, asked for as soon as the section is up. + * + * The server drew the section without asking SiriusXM, so the page arrived + * as fast as it always has; the lookup runs on the reader's own session + * here, and the rows come back as HTML from the same template as the lineup + * page. A failure is said in words with a way to try again, because "no + * feed" and "the lookup broke" must never look alike. */ - const find = section.querySelector('button[data-radio-find-button]'); const results = section.querySelector('[data-radio-results]'); - if (find && results && section.dataset.radioFind) { - find.addEventListener('click', async () => { - find.disabled = true; - find.textContent = 'Looking…'; + if (results && section.dataset.radioFind) { + const look = async () => { + results.innerHTML = '

Looking on SiriusXM…

'; try { const res = await fetch(section.dataset.radioFind, { headers: { accept: 'text/html', 'x-requested-with': 'fetch' }, @@ -1708,12 +1709,20 @@ function initRadioSection(section) { if (!res.ok) throw new Error(html.slice(0, 200) || String(res.status)); results.innerHTML = html; wire(results); - find.closest('p')?.remove(); } catch (err) { - find.disabled = false; - find.textContent = 'Find this game on SiriusXM'; - message(err?.message || 'SiriusXM did not answer. Try again in a moment.', true); + results.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'feedback error'; + p.textContent = `${err?.message || 'SiriusXM did not answer.'} `; + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'ghost small-btn'; + retry.textContent = 'Try again'; + retry.addEventListener('click', look); + p.append(retry); + results.append(p); } - }); + }; + look(); } } diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 5358177..65b4228 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -55,7 +55,7 @@ import { } from './views/pages.jsx'; import { Inbox, PeopleListPage, ProfilePage, Thread } from './views/people.jsx'; import { InvitePage, PremiumPage } from './views/premium.jsx'; -import { RadioPage, RadioRows } from './views/radio.jsx'; +import { RadioPage, RadioSidesFragment } from './views/radio.jsx'; export const app = new Hono(); @@ -467,7 +467,13 @@ app.get(`/${brand.paths.participant}/:slug`, async (c) => { * * Per-viewer, and safe only because this page is not one of the cached() ones. */ - const ownChannels = await ownChannelsForTeam({ userId: user?.id, team }); + const [ownChannels, radioSession] = await Promise.all([ + ownChannelsForTeam({ userId: user?.id, team }), + // A row read, not a lookup: the team's feed is asked for by app.js. + user && config.radio.enabled && radio.hasTeamRadio(team.league_slug) + ? radio.storedSession(user.id) + : null, + ]); return c.html( await render( @@ -477,6 +483,11 @@ app.get(`/${brand.paths.participant}/:slug`, async (c) => { events={events} following={following} ownChannels={ownChannels} + radio={ + radioSession && !radioSession.unreadable + ? { find: `/radio/find?team=${team.id}`, sides: [team.display_name] } + : null + } live={live} liveTotal={liveTotal} stalled={stalled} @@ -549,7 +560,9 @@ app.get('/events/:id', async (c) => { marketChannelsForEvent({ userId: user?.id, markets: marketsOf(event) }), // Whether the "On SiriusXM" section is drawn at all. A row read, no upstream // call: the lookup itself waits for the button. - user && config.radio.enabled ? radio.storedSession(user.id) : null, + user && config.radio.enabled && radio.hasTeamRadio(event.league_slug) + ? radio.storedSession(user.id) + : null, // Other people's open lists. Signed-in only, and it returns no URLs at all -- // a shared channel is playable through the proxy and nowhere else, because // every other route hands over the address and the address is the owner's @@ -572,7 +585,14 @@ app.get('/events/:id', async (c) => { marketChannels={marketChannels} sharedChannels={sharedChannels} streamDead={c.req.query('stream_dead') ?? null} - radioConnected={Boolean(radioSession && !radioSession.unreadable)} + radio={ + radioSession && !radioSession.unreadable + ? { + find: `/radio/find?event=${event.id}`, + sides: [event.home_name, event.away_name].filter(Boolean), + } + : null + } />, ), ); @@ -2240,7 +2260,15 @@ const radioBack = (query) => `/settings?${query}#siriusxm`; * anything else is ours and must not read as "wrong code". */ function radioFailure(err) { - if (err instanceof radio.SiriusXmError) return { message: err.message, status: err.status }; + if (err instanceof radio.SiriusXmError) { + // Logged as well as shown: the page gets the sentence, the log gets what + // SXM actually said, which is the only way a failed sign-in is diagnosable + // from here without the reader's screen. + console.warn( + `[radio] ${err.status} ${err.message}${err.data ? ` -- ${JSON.stringify(err.data).slice(0, 300)}` : ''}`, + ); + return { message: err.message, status: err.status }; + } console.error('[radio]', err); return { message: 'SiriusXM did not answer. Try again in a moment.', status: 502 }; } @@ -2274,6 +2302,46 @@ app.post('/api/radio/connect', async (c) => { } }); +/** + * The password door. Nothing is stored but the session it produces -- the + * same row the code path writes -- and the password itself is in this handler + * and nowhere else, not even the log. + */ +app.post('/api/radio/connect/password', async (c) => { + const user = requireUser(c); + if (!config.radio.enabled) return c.json({ error: 'radio is off' }, 404); + const body = await c.req.parseBody(); + const email = String(body.email ?? '').trim(); + const password = String(body.password ?? ''); + if (!email.includes('@') || !password) { + const message = 'Enter the email and password on your SiriusXM account.'; + return respond(c, { + json: { error: message }, + status: 400, + redirectTo: radioBack(`siriusxm_error=${encodeURIComponent(message)}`), + }); + } + try { + const session = await radio.passwordLogin(email, password, { + proxy: radio.proxyFor(user.id), + deviceGrant: config.radio.deviceGrant || null, + }); + radio.dropPending(user.id); + await radio.saveSession(user.id, { email, ...session }); + return respond(c, { + json: { ok: true, connected: true }, + redirectTo: radioBack('siriusxm=connected'), + }); + } catch (err) { + const { message, status } = radioFailure(err); + return respond(c, { + json: { error: message }, + status, + redirectTo: radioBack(`siriusxm_error=${encodeURIComponent(message)}`), + }); + } +}); + app.post('/api/radio/connect/verify', async (c) => { const user = requireUser(c); if (!config.radio.enabled) return c.json({ error: 'radio is off' }, 404); @@ -2365,37 +2433,50 @@ app.get('/radio', async (c) => { }); /** - * The channels naming either side of a fixture, as rows. + * The team feeds for a fixture or a team, as rows. * - * Both names are searched and the union is kept in lineup order, so a game on - * a team channel and one on a league channel both surface. Answered as HTML - * because the row already has one template, on the server. + * `?event=` looks up both sides; `?team=` one team. Only for a league SiriusXM + * carries by team -- anything else is 404, and the page never drew the section + * to ask. Each side is searched on the reader's own session, cached across + * readers, and settled separately so one side failing does not empty the + * other. Answered as HTML because the row has one template, on the server. */ app.get('/radio/find', async (c) => { const user = requireUser(c); if (!config.radio.enabled) return c.text('radio is off', 404); - const event = await q.getEvent(Number(c.req.query('event'))); - if (!event) return c.text('no such fixture', 404); - const names = [event.home_team_name, event.away_team_name, event.league_name] - .map((n) => String(n ?? '').trim()) - .filter((n) => n.length >= 3); - if (names.length === 0) return c.text('This fixture has no names to look up.', 404); - try { - const found = await Promise.all(names.map((n) => radio.search(user.id, n))); - const seen = new Set(); - const channels = found.flat().filter((ch) => { - if (seen.has(ch.stationId)) return false; - seen.add(ch.stationId); - return true; + const fragment = (node) => + c.body(node, 200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'private, no-store', }); - c.header('cache-control', 'private, no-store'); - // A fragment, not a page: no doctype, no Layout, so not render(). It is - // dropped into a section app.js already has on the page. - const fragment = - channels.length === 0 - ? '

SiriusXM has no channel naming this fixture right now. Game channels usually appear close to kickoff.

' - : await ().toString(); - return c.body(fragment, 200, { 'content-type': 'text/html; charset=utf-8' }); + + let leagueSlug = null; + let sides = []; + if (c.req.query('event')) { + const event = await q.getEvent(Number(c.req.query('event'))); + if (!event) return c.text('no such fixture', 404); + leagueSlug = event.league_slug; + const teams = await q.teamNamesByIds([event.home_team_id, event.away_team_id]); + // In fixture order, home first, with the provider's own nickname when the + // row is there and the display name alone when it is not. + sides = [event.home_team_id, event.away_team_id] + .map( + (id, i) => teams.find((t) => t.id === id) ?? (i === 0 ? event.home_name : event.away_name), + ) + .filter(Boolean); + } else if (c.req.query('team')) { + const [team] = await q.teamNamesByIds([c.req.query('team')]); + if (!team) return c.text('no such team', 404); + leagueSlug = team.league_slug; + sides = [team]; + } + if (!radio.hasTeamRadio(leagueSlug)) + return c.text('SiriusXM has no team feeds for this league.', 404); + if (sides.length === 0) return c.text('This fixture has no sides to look up.', 404); + + try { + const result = await radio.sidesStations(user.id, leagueSlug, sides); + return fragment(await ().toString()); } catch (err) { const { message, status } = radioFailure(err); return c.text(message, status); diff --git a/apps/web/src/views/pages.jsx b/apps/web/src/views/pages.jsx index 4c9b7f6..910fabd 100644 --- a/apps/web/src/views/pages.jsx +++ b/apps/web/src/views/pages.jsx @@ -10,7 +10,7 @@ import { TeamRow, } from './components.jsx'; import { Layout } from './Layout.jsx'; -import { RadioEventSection, RadioSettings } from './radio.jsx'; +import { RadioSettings, RadioTeamSection } from './radio.jsx'; /* * A sentence describing THIS page, for the meta description and the share cards. @@ -955,6 +955,9 @@ export const TeamPage = ({ soonTotal = 0, soonHours = 4, stalled = 0, + // The team's own SiriusXM feed, for a connected reader on a league that has + // them. See views/radio.jsx. + radio = null, }) => { const liveEmpty = `${team.display_name} are not playing right now.`; const soonEmpty = `${team.display_name} are not on in the next ${soonHours} hours.`; @@ -1040,6 +1043,8 @@ export const TeamPage = ({ Below the fixture list and the reader's own line, because both of those are why somebody opened a team page. This answers the narrower question they may not have thought to ask: is this lot playing at this moment. */} + {radio ? : null} + { const live = event.state === 'in'; const done = event.state === 'post'; @@ -1880,10 +1885,11 @@ export const EventPage = ({ ) : null} - {/* The reader's own SiriusXM, when connected. Radio rather than television, - so it sits after both TV rails; the same one-stream-at-a-time rule applies - across all three and app.js enforces it. */} - {radioConnected ? : null} + {/* The reader's own SiriusXM, when connected and the league has team feeds. + Radio rather than television, so it sits after both TV rails; the same + one-stream-at-a-time rule applies across all three and app.js enforces + it. */} + {radio ? : null}

Watch

diff --git a/apps/web/src/views/radio.jsx b/apps/web/src/views/radio.jsx index b4d8c51..4b4b9c6 100644 --- a/apps/web/src/views/radio.jsx +++ b/apps/web/src/views/radio.jsx @@ -172,34 +172,54 @@ export const RadioPage = ({ ); /** - * The event page's slice of this: the channels that name either side. + * A fixture's or a team's own broadcasts, on SiriusXM. * - * Nothing is fetched at render. Searching SiriusXM is an upstream call on the - * reader's own session, and an event page is opened far more often than a - * reader wants radio for it -- so the section offers a button, and app.js asks - * `/radio/find` only when it is pressed. Rendered only for a connected reader, - * like the playlist rail above it. + * Drawn for a connected reader on a league SiriusXM carries by team, and + * filled in by app.js the moment the page is up: `data-radio-find` is the + * fragment route, and nothing upstream is asked at render, so the fixture page + * stays as fast as it was. The rows arrive as HTML from the same component the + * lineup page uses, so there is one row template and it is on the server. + * + * @param {{find: string, sides: string[]}} props what to look up, and who for */ -export const RadioEventSection = ({ event }) => ( -
+export const RadioTeamSection = ({ find, sides }) => ( +

On SiriusXM

- SiriusXM carries most major-league games on a channel of their own. Look one up for this - fixture and it plays here, on your subscription. -

-

- + {sides.length === 1 + ? `${sides[0]}'s own broadcast, when SiriusXM is carrying it. ` + : `Each side's own broadcast, when SiriusXM is carrying it. `} + Team feeds appear close to kickoff and go away after the final whistle. These play on your + subscription, here and nowhere else.

-
+
+

Looking on SiriusXM…

+
); +/** + * The answer, per side. A side with nothing is said so, in words, because + * "no feed yet" and "the lookup broke" must never look the same; a side whose + * lookup failed says what SiriusXM said. + */ +export const RadioSidesFragment = ({ sides }) => ( + <> + {sides.map((side) => ( +
+

{side.team}

+ {side.error ? ( + + ) : side.stations.length === 0 ? ( +

No {side.team} feed on SiriusXM right now.

+ ) : ( + + )} +
+ ))} + +); + /** * The settings section. Three states, one form each: not connected (ask for * the email), waiting for the code (ask for the code), connected (offer to @@ -270,7 +290,7 @@ export const RadioSettings = ({ session, pending, notice, error }) => ( ) : ( -
+

Connect your SiriusXM account

{session?.unreadable ? (

+ The password is used once, to sign in as the SiriusXM app would, and is not stored. What + is kept is the session it produces, encrypted. No password, or would rather not? Leave it + blank and have SiriusXM email you a code instead. +

+
+ + +
)}
diff --git a/packages/db/src/queries.js b/packages/db/src/queries.js index 48f9a5e..6958a24 100644 --- a/packages/db/src/queries.js +++ b/packages/db/src/queries.js @@ -782,6 +782,17 @@ export async function deleteSiriusXm(userId) { await sql`delete from siriusxm_sessions where user_id = ${userId}`; } +/** The two halves of a team's name, and its league, for matching a station to it. */ +export async function teamNamesByIds(ids) { + const wanted = (ids ?? []).map(Number).filter(Number.isFinite); + if (wanted.length === 0) return []; + return sql` + select t.id, t.name, t.display_name, l.slug as league_slug + from teams t left join leagues l on l.id = t.league_id + where t.id = any(${pgArray(wanted)}::bigint[]) + `; +} + /* ----------------------------------------------------- sharing a playlist -- */ /** * Record a probe verdict on a SHARED entry. diff --git a/packages/radio/src/index.js b/packages/radio/src/index.js index d97e6bd..20c769e 100644 --- a/packages/radio/src/index.js +++ b/packages/radio/src/index.js @@ -31,6 +31,7 @@ export { isSiriusXmUrl, looksLikePlaylist, parseStationId, + passwordLogin, QUALITIES, rewritePlaylist, SiriusXmError, @@ -38,4 +39,12 @@ export { stationId, sxmFetch, } from './siriusxm.js'; +export { + hasTeamRadio, + matchesTeam, + sidesStations, + TEAM_RADIO_LEAGUES, + teamStations, + teamTerms, +} from './teams.js'; export { sharedFetch } from './upstream-cache.js'; diff --git a/packages/radio/src/siriusxm.js b/packages/radio/src/siriusxm.js index 3c55ba1..d459dbb 100644 --- a/packages/radio/src/siriusxm.js +++ b/packages/radio/src/siriusxm.js @@ -428,6 +428,67 @@ export async function completeOtpLogin(state, otp, { proxy = null } = {}) { return extractSession(authed, jar); } +/** + * The other door: the account password. + * + * What the SiriusXM web player does when a reader types a password rather than + * asking for a code (read from its bundle, 2026-09-03: endpoint + * `authenticatePassword`, body `{handle, password}`, bearer the anonymous + * session's access token; the reply is an identity grant, and + * `sessions/authenticated` turns it into a session the same way the OTP path + * does). Tried first with no bearer, as the OTP start is; only if the gateway + * insists is the anonymous session minted underneath it. + * + * The password is used for this one call and never stored -- what is kept is + * the session it produced, which is all the OTP path keeps too. + */ +export async function passwordLogin(handle, password, { proxy = null, deviceGrant = null } = {}) { + let jar = ''; + const body = { handle, password }; + const attempt = (bearer) => + sxmCall('identity/v1/identities/authenticate/password', { + bearer: bearer || undefined, + cookies: jar, + body, + proxy, + }); + + let reply = await attempt(''); + if (reply.status === 401 || reply.status === 403) { + const grant = await bootstrapDeviceGrant({ proxy, pasted: deviceGrant }); + const anon = await sxmCall('session/v1/sessions/anonymous', { bearer: grant.grant, proxy }); + if (anon.status >= 400) { + throw new SiriusXmError(`anonymous session failed: ${anon.status}`, 502, anon.data); + } + jar = mergeCookies(jar, anon.setCookie); + reply = await attempt(extractSession(anon, jar).accessToken); + } + if (reply.status >= 400) { + throw new SiriusXmError( + [400, 401, 403, 404].includes(reply.status) + ? 'SiriusXM did not accept that email and password.' + : `SiriusXM would not sign in (${reply.status}).`, + reply.status >= 500 ? 502 : 400, + reply.data, + ); + } + jar = mergeCookies(jar, reply.setCookie); + const identityGrant = reply.data?.grant ?? reply.data?.identityGrant?.grant; + if (!identityGrant) { + throw new SiriusXmError(`no grant in password response: ${reply.raw.slice(0, 300)}`, 502); + } + + const authed = await sxmCall('session/v1/sessions/authenticated', { + bearer: identityGrant, + cookies: jar, + proxy, + }); + if (authed.status >= 400) { + throw new SiriusXmError(`sessions/authenticated failed: ${authed.status}`, 502, authed.data); + } + return extractSession(authed, jar); +} + /** A new access token from the jar alone. No bearer: the cookies are the refresh. */ export async function refreshSession(cookies, { proxy = null } = {}) { const reply = await sxmCall('session/v1/sessions/refresh', { cookies, body: {}, proxy }); diff --git a/packages/radio/src/teams.js b/packages/radio/src/teams.js new file mode 100644 index 0000000..4e99f0b --- /dev/null +++ b/packages/radio/src/teams.js @@ -0,0 +1,171 @@ +/** + * A team's own broadcast on SiriusXM. + * + * For the leagues SiriusXM carries by team -- the American national ones -- + * every game has a home feed and an away feed on channels of their own, named + * for the team, and they are what a fan wants over the national call. They + * come and go with the schedule, so they cannot be a list: they are found by + * searching SiriusXM for the team's name and keeping what actually names it. + * + * The search is upstream, on the reader's own session, so it is bounded here: + * one lookup per team name every few minutes, shared by every reader on the + * site, because a Broncos feed is the same Broncos feed whoever asks. + */ + +import { search } from './session.js'; + +/** + * Leagues where a team has a station of its own, by our league slug. + * + * ESPN slugs. College is in because SiriusXM carries the big conferences by + * school; MLS because it carries a game-of-the-week by club. Nothing outside + * the United States, because that is where the team feeds are -- a Premier + * League club has no SiriusXM channel to find, and searching for one is a + * wasted call on a reader's session. + */ +export const TEAM_RADIO_LEAGUES = new Set([ + 'nfl', + 'nba', + 'mlb', + 'nhl', + 'wnba', + 'college-football', + 'mens-college-basketball', + 'womens-college-basketball', + 'usa.1', +]); + +export const hasTeamRadio = (leagueSlug) => TEAM_RADIO_LEAGUES.has(String(leagueSlug ?? '')); + +/** Suffixes that are not a nickname. "Inter Miami CF" is not nicknamed "CF". */ +const NOT_A_NICKNAME = new Set(['fc', 'sc', 'cf', 'afc', 'united']); + +const fold = (s) => + String(s ?? '') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + +/** + * The names a team goes by, from what the provider gives us. + * + * `nickname` is the provider's own short name when we have it ("Broncos", + * "Crimson Tide") and the last word of the display name otherwise; `place` is + * what is left ("Denver", "Alabama"). A college feed is usually named for the + * school rather than the mascot, so both halves matter. + * + * @param {{display_name?: string, name?: string}|string} team + */ +export function teamTerms(team) { + const display = typeof team === 'string' ? team : (team?.display_name ?? team?.name ?? ''); + const provided = typeof team === 'string' ? '' : (team?.name ?? ''); + const full = fold(display); + const words = full.split(' ').filter(Boolean); + + let nickname = fold(provided); + if (!nickname || nickname === full) { + const tail = [...words]; + while (tail.length > 1 && NOT_A_NICKNAME.has(tail[tail.length - 1])) tail.pop(); + nickname = tail.length > 1 ? tail[tail.length - 1] : ''; + } + const place = nickname && full.endsWith(nickname) ? full.slice(0, -nickname.length).trim() : ''; + return { full, nickname, place, display }; +} + +const hasWord = (haystack, needle) => + Boolean(needle) && + new RegExp(`(^|\\s)${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`).test(haystack); + +/** + * How well a channel names a team. 0 is not at all. + * + * The full name is the strong signal. A nickname alone is accepted only when + * it is distinctive enough to be one -- four letters and up, so "Jets" and + * "Kings" count but a stray "Sox" does not -- and the place alone only for a + * college, where "Alabama" IS the team on the air. + */ +export function matchesTeam(channel, terms, { college = false } = {}) { + const text = fold(`${channel?.title ?? ''} ${channel?.description ?? ''}`); + if (!text) return 0; + if (terms.full && text.includes(terms.full)) return 3; + if (terms.nickname.length >= 4 && hasWord(text, terms.nickname)) return 2; + if (college && terms.place.length >= 4 && hasWord(text, terms.place)) return 1; + return 0; +} + +/* + * Found stations, by folded team name, for a few minutes. Negative answers are + * kept too, more briefly: a team with no game today has no feed, and asking + * SiriusXM again on every page view would be the cost this cache exists to + * remove. + */ +const HIT_TTL_MS = 10 * 60 * 1000; +const MISS_TTL_MS = 3 * 60 * 1000; +const found = new Map(); + +/** + * The stations naming one team, best match first. + * + * @param {string} userId whose session performs the search + * @param {{display_name?: string, name?: string}|string} team + * @param {{college?: boolean}} [opts] + */ +export async function teamStations(userId, team, { college = false } = {}) { + const terms = teamTerms(team); + if (!terms.full) return []; + const key = `${terms.full}|${college ? 'c' : ''}`; + const hit = found.get(key); + if (hit && hit.expiresAt > Date.now()) return hit.stations; + if (hit?.pending) return hit.pending; + + const pending = search(userId, terms.display) + .then((channels) => { + const stations = channels + .map((ch) => ({ ch, score: matchesTeam(ch, terms, { college }) })) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score || (a.ch.number ?? 1e9) - (b.ch.number ?? 1e9)) + .map((x) => x.ch); + found.set(key, { + stations, + expiresAt: Date.now() + (stations.length ? HIT_TTL_MS : MISS_TTL_MS), + }); + return stations; + }) + .catch((err) => { + found.delete(key); + throw err; + }); + found.set(key, { pending, expiresAt: 0 }); + return pending; +} + +const isCollege = (leagueSlug) => /college/.test(String(leagueSlug ?? '')); + +/** + * Both sides of a fixture, each with what SiriusXM has for them. + * + * Searched in parallel and settled separately: one side failing must not + * take the other side's feed off the page. A failure is carried as `error` + * so the section can say which side it could not ask about. + * + * @returns {Promise>} + */ +export async function sidesStations(userId, leagueSlug, sides) { + const college = isCollege(leagueSlug); + const results = await Promise.allSettled( + sides.map((team) => teamStations(userId, team, { college })), + ); + return sides.map((team, i) => { + const r = results[i]; + const display = teamTerms(team).display; + return r.status === 'fulfilled' + ? { team: display, stations: r.value } + : { team: display, stations: [], error: r.reason?.message ?? 'SiriusXM did not answer.' }; + }); +} + +export function _resetTeamCache() { + found.clear(); +} diff --git a/test/radio-config.test.js b/test/radio-config.test.js index b6ce5ab..b96d8a1 100644 --- a/test/radio-config.test.js +++ b/test/radio-config.test.js @@ -25,4 +25,14 @@ describe('config.radio', () => { expect(config.radio.enabled).toBe(false); delete process.env.SIRIUSXM; }); + + test('off for genrewatch unless asked for: that site is VOD only', () => { + process.env.BRAND = 'genrewatch'; + expect(config.radio.enabled).toBe(false); + process.env.SIRIUSXM = '1'; + expect(config.radio.enabled).toBe(true); + delete process.env.SIRIUSXM; + delete process.env.BRAND; + expect(config.radio.enabled).toBe(true); + }); }); diff --git a/test/radio-pending.test.js b/test/radio-pending.test.js index d5ab35c..e733d49 100644 --- a/test/radio-pending.test.js +++ b/test/radio-pending.test.js @@ -20,7 +20,7 @@ describe('pending sign-ins', () => { expect(peekPending('u1')).toBeNull(); }); - test('one reader cannot see another\'s', () => { + test("one reader cannot see another's", () => { putPending('u1', { email: 'a@b.c' }); expect(peekPending('u2')).toBeNull(); dropPending('u1'); diff --git a/test/radio-protocol.test.js b/test/radio-protocol.test.js index 9a20a97..f4650bf 100644 --- a/test/radio-protocol.test.js +++ b/test/radio-protocol.test.js @@ -26,8 +26,7 @@ describe('cookie jar', () => { }); describe('jwt expiry', () => { - const token = (exp) => - `h.${Buffer.from(JSON.stringify({ exp })).toString('base64url')}.s`; + const token = (exp) => `h.${Buffer.from(JSON.stringify({ exp })).toString('base64url')}.s`; test('reads exp in seconds and answers in ms', () => { expect(sxm.jwtExpiryMs(token(1_700_000_000))).toBe(1_700_000_000_000); }); @@ -139,7 +138,12 @@ describe('hls rewriting', () => { 'audio_128k_v3.m3u8', '', ].join('\n'); - const out = sxm.rewritePlaylist(master, 'https://cdn.siriusxm.com/ch/master.m3u8', '128', proxify); + const out = sxm.rewritePlaylist( + master, + 'https://cdn.siriusxm.com/ch/master.m3u8', + '128', + proxify, + ); expect(out).toContain('BANDWIDTH=128000'); expect(out).not.toContain('256000'); expect(out).toContain(proxify('https://cdn.siriusxm.com/ch/audio_128k_v3.m3u8')); @@ -155,7 +159,12 @@ describe('hls rewriting', () => { '/abs/seg002.aac', '', ].join('\n'); - const out = sxm.rewritePlaylist(media, 'https://cdn.siriusxm.com/ch/a/media.m3u8', '256', proxify); + const out = sxm.rewritePlaylist( + media, + 'https://cdn.siriusxm.com/ch/a/media.m3u8', + '256', + proxify, + ); const lines = out.split('\n'); expect(lines[1]).toBe('#EXT-X-TARGETDURATION:10'); expect(lines[2]).toContain( @@ -186,9 +195,9 @@ describe('key decoding', () => { const bytes = Buffer.from('0123456789abcdef'); test('base64, base64url, hex and literal', () => { expect(sxm.decodeKeyJson({ key: bytes.toString('base64') }).equals(bytes)).toBe(true); - expect(sxm.decodeKeyJson({ result: { value: bytes.toString('base64url') } }).equals(bytes)).toBe( - true, - ); + expect( + sxm.decodeKeyJson({ result: { value: bytes.toString('base64url') } }).equals(bytes), + ).toBe(true); // A literal that is neither alphabet: taken as the bytes it is. expect(sxm.decodeKeyJson({ data: 'raw key sixteen!' }).length).toBe(16); }); @@ -224,7 +233,12 @@ describe('otp login', () => { fetch: async (req) => { const url = new URL(req.url); const auth = req.headers.get('authorization'); - calls.push({ method: req.method, path: url.pathname, auth, cookie: req.headers.get('cookie') }); + calls.push({ + method: req.method, + path: url.pathname, + auth, + cookie: req.headers.get('cookie'), + }); const json = (data, status = 200, headers = {}) => new Response(JSON.stringify(data), { status, @@ -250,6 +264,14 @@ describe('otp login', () => { case 'POST /identity/v1/identities/authenticate/otp': if (auth !== 'Bearer otp-grant') return json({}, 403); return json({ grant: 'identity-grant' }); + case 'POST /identity/v1/identities/authenticate/password': { + if (!auth) return json({ error: 'auth' }, 401); + const body = await req.json(); + if (body.handle !== 'me@example.com' || body.password !== 'hunter2') { + return json({ error: 'bad credentials' }, 401); + } + return json({ grant: 'identity-grant' }, 200, { 'set-cookie': 'pw=1' }); + } case 'POST /session/v1/sessions/authenticated': if (auth !== 'Bearer identity-grant') return json({}, 403); return json(session('access-1'), 200, { 'set-cookie': 'refresh=r1; HttpOnly' }); @@ -304,7 +326,7 @@ describe('otp login', () => { expect(calls[4].cookie).toContain('sxm=jar1'); }); - test('an unknown email is the reader\'s problem, not ours', async () => { + test("an unknown email is the reader's problem, not ours", async () => { await expect( sxm.startOtpLogin('nobody@example.com', { deviceGrant: JSON.stringify({ grant: 'device-grant' }), @@ -322,6 +344,37 @@ describe('otp login', () => { }); }); + test('the password door: anonymous session, then the grant, then the session', async () => { + calls.length = 0; + const result = await sxm.passwordLogin('me@example.com', 'hunter2', { + deviceGrant: JSON.stringify({ grant: 'device-grant' }), + }); + expect(result.accessToken).toBe('access-1'); + expect(result.cookies).toContain('refresh=r1'); + const paths = calls.map((c) => `${c.method} ${c.path}`); + expect(paths).toEqual([ + 'POST /identity/v1/identities/authenticate/password', + 'POST /session/v1/sessions/anonymous', + 'POST /identity/v1/identities/authenticate/password', + 'POST /session/v1/sessions/authenticated', + ]); + expect(calls[2].auth).toBe('Bearer anon-token'); + expect(calls[3].auth).toBe('Bearer identity-grant'); + // The jar from the anonymous session rides along to the password call. + expect(calls[2].cookie).toContain('anon=1'); + }); + + test('a wrong password is a 400 in words, never a 502', async () => { + await expect( + sxm.passwordLogin('me@example.com', 'nope', { + deviceGrant: JSON.stringify({ grant: 'device-grant' }), + }), + ).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining('email and password'), + }); + }); + test('refresh replays the jar with no bearer', async () => { calls.length = 0; const r = await sxm.refreshSession('refresh=r1; other=2'); diff --git a/test/radio-routes.test.js b/test/radio-routes.test.js index 4ff8722..39d12d4 100644 --- a/test/radio-routes.test.js +++ b/test/radio-routes.test.js @@ -1,5 +1,5 @@ -import { readFile } from 'node:fs/promises'; import { describe, expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; /* * Shape assertions on the routes, for the properties that a session-less test @@ -19,6 +19,7 @@ describe('radio routes', () => { expect(guard).toBeLessThan(fetchAt); for (const route of [ "app.post('/api/radio/connect'", + "app.post('/api/radio/connect/password'", "app.post('/api/radio/connect/verify'", "app.post('/api/radio/disconnect'", "app.get('/radio/find'", @@ -32,15 +33,26 @@ describe('radio routes', () => { test('a manifest leaves with root-relative proxy addresses and no-store', async () => { const src = await read('../apps/web/src/app.js'); - expect(src).toContain('`/radio/proxy?u=${encodeURIComponent(target)}'); + expect(src).toMatch(/`\/radio\/proxy\?u=\$\{encodeURIComponent\(target\)\}/); const resource = src.slice(src.indexOf('function radioResource')); expect(resource.slice(0, 2000)).toContain("'cache-control': 'no-store, private'"); }); + test('the password is used once and never reaches a log or the database', async () => { + const src = await read('../apps/web/src/app.js'); + const route = src.slice(src.indexOf("app.post('/api/radio/connect/password'")); + const body = route.slice(0, route.indexOf("app.post('/api/radio/connect/verify'")); + expect(body).toContain('radio.passwordLogin(email, password'); + expect(body).not.toMatch(/console\.\w+\([^)]*password/); + expect(body).not.toMatch(/saveSession\([^)]*password/); + }); + test('a wrong code keeps the sign-in; an expired one says so', async () => { const src = await read('../apps/web/src/app.js'); const verify = src.slice(src.indexOf("app.post('/api/radio/connect/verify'")); - expect(verify.slice(0, 2500)).toContain('if (status === 400) radio.putPending(user.id, pending)'); + expect(verify.slice(0, 2500)).toContain( + 'if (status === 400) radio.putPending(user.id, pending)', + ); expect(verify.slice(0, 2500)).toContain('That code has expired'); }); @@ -55,11 +67,34 @@ describe('radio routes', () => { expect(build).toContain("external: ['mpegts.js']"); }); + test('the team lookup is gated on a league with team feeds, for a fixture and for a team', async () => { + const src = await read('../apps/web/src/app.js'); + const find = src.slice(src.indexOf("app.get('/radio/find'")); + expect(find.slice(0, 2500)).toContain('radio.hasTeamRadio(leagueSlug)'); + expect(find.slice(0, 2500)).toContain("c.req.query('event')"); + expect(find.slice(0, 2500)).toContain("c.req.query('team')"); + // The pages draw the section only for those leagues, and never look up at render. + expect(src).toContain('radio.hasTeamRadio(event.league_slug)'); + expect(src).toContain('radio.hasTeamRadio(team.league_slug)'); + expect(src.match(/radio\.sidesStations\(/g)).toHaveLength(1); + }); + + test('app.js looks the feeds up as soon as the section is on the page', async () => { + const client = await read('../apps/web/public/app.js'); + const radio = client.slice(client.indexOf('function initRadioSection')); + expect(radio).toContain('section.dataset.radioFind'); + expect(radio).toContain('look();'); + expect(radio).toContain("retry.textContent = 'Try again'"); + }); + test('app.js wires the radio sections at boot and after a client-side navigation', async () => { const client = await read('../apps/web/public/app.js'); expect(client).toContain('function initRadio('); expect(client.match(/^initRadio\(\);/m)).not.toBeNull(); - const nav = client.slice(client.indexOf('function initNavigation'), client.indexOf('function initNavigation') + 2500); + const nav = client.slice( + client.indexOf('function initNavigation'), + client.indexOf('function initNavigation') + 2500, + ); expect(nav).toContain('initRadio();'); // One stream per reader, across TV and radio alike. const radio = client.slice(client.indexOf('function initRadioSection')); diff --git a/test/radio-schema.test.js b/test/radio-schema.test.js index 6bf8733..9f8b9cc 100644 --- a/test/radio-schema.test.js +++ b/test/radio-schema.test.js @@ -1,8 +1,8 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { readdir, readFile } from 'node:fs/promises'; import { PGlite } from '@electric-sql/pglite'; import { citext } from '@electric-sql/pglite/contrib/citext'; import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm'; -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; process.env.DATABASE_URL ??= 'postgres://localhost:5432/unused'; process.env.PLAYLIST_SECRET ??= 'test-secret-for-sealing-values'; @@ -27,7 +27,10 @@ describe('siriusxm_sessions', () => { [u.id, 'r@sxm', 'v1.sealed', 'v1.sealed'], ); await expect( - db.query('insert into siriusxm_sessions (user_id, access_token) values ($1, $2)', [u.id, 'x']), + db.query('insert into siriusxm_sessions (user_id, access_token) values ($1, $2)', [ + u.id, + 'x', + ]), ).rejects.toThrow(); await db.query('delete from users where id = $1', [u.id]); const { rows } = await db.query('select count(*)::int as n from siriusxm_sessions'); @@ -53,13 +56,13 @@ describe('siriusxm_sessions', () => { describe('proxy pinning', () => { test('a reader always exits the same entry, and readers spread across the pool', async () => { - process.env.SIRIUSXM_PROXIES = Array.from({ length: 8 }, (_, i) => `http://u:p@h${i}:1`).join(','); + process.env.SIRIUSXM_PROXIES = Array.from({ length: 8 }, (_, i) => `http://u:p@h${i}:1`).join( + ',', + ); const { proxyFor } = await import('../packages/radio/src/session.js'); const a = proxyFor('11111111-1111-1111-1111-111111111111'); expect(proxyFor('11111111-1111-1111-1111-111111111111')).toBe(a); - const seen = new Set( - Array.from({ length: 40 }, (_, i) => proxyFor(`user-${i}`)), - ); + const seen = new Set(Array.from({ length: 40 }, (_, i) => proxyFor(`user-${i}`))); expect(seen.size).toBeGreaterThan(3); delete process.env.SIRIUSXM_PROXIES; process.env.SIRIUSXM_PROXY_URL = 'http://rotate:1'; diff --git a/test/radio-teams.test.js b/test/radio-teams.test.js new file mode 100644 index 0000000..dc52a13 --- /dev/null +++ b/test/radio-teams.test.js @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test'; + +process.env.DATABASE_URL ??= 'postgres://localhost:5432/unused'; +process.env.PLAYLIST_SECRET ??= 'test-secret-for-sealing-values'; + +const { hasTeamRadio, matchesTeam, TEAM_RADIO_LEAGUES, teamTerms } = await import( + '../packages/radio/src/teams.js' +); + +/* + * Finding a team's own feed: which leagues have one, how a name is split, and + * what counts as a channel naming the team. The search itself is SiriusXM's; + * these are the parts that decide what is asked and what is kept. + */ +describe('leagues with team feeds', () => { + test('the American national leagues, by our slug, and nothing abroad', () => { + for (const slug of ['nfl', 'nba', 'mlb', 'nhl', 'college-football', 'usa.1']) { + expect(hasTeamRadio(slug)).toBe(true); + } + for (const slug of ['eng.1', 'uefa.champions', 'f1', 'atp', null, undefined, '']) { + expect(hasTeamRadio(slug)).toBe(false); + } + expect(TEAM_RADIO_LEAGUES.size).toBeGreaterThan(5); + }); +}); + +describe('team names', () => { + test('uses the provider nickname when there is one', () => { + const t = teamTerms({ display_name: 'Denver Broncos', name: 'Broncos' }); + expect(t).toMatchObject({ full: 'denver broncos', nickname: 'broncos', place: 'denver' }); + }); + test('falls back to the last word, skipping club suffixes', () => { + expect(teamTerms('Tampa Bay Buccaneers').nickname).toBe('buccaneers'); + expect(teamTerms('Inter Miami CF').nickname).toBe('miami'); + // "United" is half the clubs in the world; with no nickname left, only the + // full name can match, which is the right amount of caution. + expect(teamTerms('Atlanta United FC').nickname).toBe(''); + expect(teamTerms('LA Galaxy').place).toBe('la'); + }); + test('a multi-word nickname from the provider keeps the school as the place', () => { + const t = teamTerms({ display_name: 'Alabama Crimson Tide', name: 'Crimson Tide' }); + expect(t.nickname).toBe('crimson tide'); + expect(t.place).toBe('alabama'); + }); + test('one word is a name with no nickname', () => { + expect(teamTerms('Arsenal')).toMatchObject({ full: 'arsenal', nickname: '', place: '' }); + }); +}); + +describe('matching a channel to a team', () => { + const broncos = teamTerms({ display_name: 'Denver Broncos', name: 'Broncos' }); + const jets = teamTerms({ display_name: 'New York Jets', name: 'Jets' }); + const sox = teamTerms({ display_name: 'Boston Red Sox', name: 'Red Sox' }); + const bama = teamTerms({ display_name: 'Alabama Crimson Tide', name: 'Crimson Tide' }); + + test('the full name is the strong match, in the title or the description', () => { + expect(matchesTeam({ title: 'Denver Broncos', description: '' }, broncos)).toBe(3); + expect( + matchesTeam({ title: 'NFL Home Feed', description: 'Denver Broncos vs Chiefs' }, broncos), + ).toBe(3); + }); + test('a distinctive nickname alone is enough; a short one is not', () => { + expect(matchesTeam({ title: 'Broncos Radio' }, broncos)).toBe(2); + expect(matchesTeam({ title: 'Jets Radio Network' }, jets)).toBe(2); + // "Sox" would also be the White Sox; the two-word nickname is what is looked for. + expect(matchesTeam({ title: 'Sox Talk' }, sox)).toBe(0); + expect(matchesTeam({ title: 'Red Sox Radio' }, sox)).toBe(2); + }); + test('word boundaries: Jets is not Jetsons', () => { + expect(matchesTeam({ title: 'The Jetsons Hour' }, jets)).toBe(0); + }); + test('a college is found by its school, and a pro team is not found by its city', () => { + expect(matchesTeam({ title: 'Alabama Football' }, bama, { college: true })).toBe(1); + expect(matchesTeam({ title: 'Alabama Football' }, bama)).toBe(0); + expect(matchesTeam({ title: 'Denver Sports Talk' }, broncos)).toBe(0); + }); + test('nothing matches nothing', () => { + expect(matchesTeam({ title: '' }, broncos)).toBe(0); + expect(matchesTeam(null, broncos)).toBe(0); + }); +}); diff --git a/test/radio-views.test.js b/test/radio-views.test.js index ecea57c..df5bd61 100644 --- a/test/radio-views.test.js +++ b/test/radio-views.test.js @@ -4,9 +4,8 @@ process.env.DATABASE_URL ??= 'postgres://localhost:5432/unused'; process.env.PLAYLIST_SECRET ??= 'test-secret-for-sealing-values'; process.env.SITE_URL ??= 'https://tipoffwatch.com'; -const { RadioChannelRow, RadioEventSection, RadioPage, RadioSettings } = await import( - '../apps/web/src/views/radio.jsx' -); +const { RadioChannelRow, RadioPage, RadioSettings, RadioSidesFragment, RadioTeamSection } = + await import('../apps/web/src/views/radio.jsx'); const { Layout } = await import('../apps/web/src/views/Layout.jsx'); const html = (node) => node.toString(); @@ -40,10 +39,13 @@ describe('RadioChannelRow', () => { }); describe('RadioSettings', () => { - test('asks for the email when nothing is connected', () => { + test('asks for email and password when nothing is connected, with the code as the other door', () => { const out = html(RadioSettings({ session: null, pending: null })); - expect(out).toContain('action="/api/radio/connect"'); + expect(out).toContain('action="/api/radio/connect/password"'); expect(out).toContain('name="email"'); + expect(out).toContain('type="password"'); + expect(out).toContain('autocomplete="current-password"'); + expect(out).toContain('formaction="/api/radio/connect"'); expect(out).not.toContain('name="otp"'); }); test('asks for the code while one is pending', () => { @@ -61,10 +63,12 @@ describe('RadioSettings', () => { expect(on).not.toContain('name="email"'); const broken = html(RadioSettings({ session: { unreadable: true }, pending: null })); expect(broken).toContain('no longer be decrypted'); - expect(broken).toContain('action="/api/radio/connect"'); + expect(broken).toContain('action="/api/radio/connect/password"'); }); test('shows what happened', () => { - const out = html(RadioSettings({ session: null, pending: null, notice: 'Done.', error: 'Nope.' })); + const out = html( + RadioSettings({ session: null, pending: null, notice: 'Done.', error: 'Nope.' }), + ); expect(out).toContain('feedback ok'); expect(out).toContain('feedback error'); }); @@ -82,7 +86,9 @@ describe('RadioPage', () => { expect(out).not.toContain('data-radio-src'); }); test('connected: tabs, search, quality and the rows, with the bundle attributes', () => { - const out = html(RadioPage({ user: { id: 'u' }, session: { email: 'a@b.c' }, cat: 'news', channels: [ch] })); + const out = html( + RadioPage({ user: { id: 'u' }, session: { email: 'a@b.c' }, cat: 'news', channels: [ch] }), + ); expect(out).toMatch(/data-radio-src="\/vendor-player\.js(\?v=[^"]+)?"/); expect(out).toMatch(/data-radio-css="\/vendor-player\.css(\?v=[^"]+)?"/); expect(out).toContain('href="/radio?cat=news" class="active"'); @@ -91,17 +97,40 @@ describe('RadioPage', () => { expect(out).toContain('ESPN Radio'); }); test('an error from SiriusXM is shown, not swallowed', () => { - const out = html(RadioPage({ user: { id: 'u' }, session: { email: 'a' }, channels: [], error: 'SXM said no' })); + const out = html( + RadioPage({ user: { id: 'u' }, session: { email: 'a' }, channels: [], error: 'SXM said no' }), + ); expect(out).toContain('SXM said no'); }); }); -describe('RadioEventSection', () => { - test('offers a lookup and fetches nothing at render', () => { - const out = html(RadioEventSection({ event: { id: 42 } })); +describe('RadioTeamSection', () => { + test('names the lookup and fetches nothing at render', () => { + const out = html(RadioTeamSection({ find: '/radio/find?event=42', sides: ['A', 'B'] })); expect(out).toContain('data-radio-find="/radio/find?event=42"'); - expect(out).toContain('data-radio-find-button'); expect(out).toContain('data-radio-results'); + expect(out).toContain('Looking on SiriusXM'); + expect(out).toContain('Each side's own broadcast'); + const one = html(RadioTeamSection({ find: '/radio/find?team=7', sides: ['Denver Broncos'] })); + expect(one).toContain('Denver Broncos's own broadcast'); + }); +}); + +describe('RadioSidesFragment', () => { + test('a side with a feed gets rows; without one, words; a failure, its reason', () => { + const out = html( + RadioSidesFragment({ + sides: [ + { team: 'Denver Broncos', stations: [ch] }, + { team: 'Kansas City Chiefs', stations: [] }, + { team: 'Nobody', stations: [], error: 'SXM said no' }, + ], + }), + ); + expect(out).toContain('ESPN Radio'); + expect(out).toContain('No Kansas City Chiefs feed on SiriusXM right now.'); + expect(out).toContain('SXM said no'); + expect(out).not.toContain('