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.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 }) => (
) : (
-
)}
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('