Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 23 additions & 14 deletions apps/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<p class="muted small radio-looking">Looking on SiriusXM…</p>';
try {
const res = await fetch(section.dataset.radioFind, {
headers: { accept: 'text/html', 'x-requested-with': 'fetch' },
Expand All @@ -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();
}
}
141 changes: 111 additions & 30 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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(
Expand All @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
/>,
),
);
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
? '<p class="muted">SiriusXM has no channel naming this fixture right now. Game channels usually appear close to kickoff.</p>'
: await (<RadioRows channels={channels} />).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 (<RadioSidesFragment sides={result} />).toString());
} catch (err) {
const { message, status } = radioFailure(err);
return c.text(message, status);
Expand Down
22 changes: 14 additions & 8 deletions apps/web/src/views/pages.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.`;
Expand Down Expand Up @@ -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 ? <RadioTeamSection {...radio} /> : null}

<LiveSection
title={brand.copy.liveTitle}
blurb={brand.copy.liveBlurb}
Expand Down Expand Up @@ -1361,9 +1366,9 @@ export const EventPage = ({
// Channels from lists other accounts have opened. Never carries a URL.
sharedChannels = null,
streamDead = null,
// Whether the reader has a SiriusXM session on file. A section is drawn, not a
// lookup: the lookup waits for the button.
radioConnected = false,
// The SiriusXM section's props, or null: connected reader, league SiriusXM
// carries by team. A section is drawn, not a lookup; app.js asks for the rows.
radio = null,
}) => {
const live = event.state === 'in';
const done = event.state === 'post';
Expand Down Expand Up @@ -1880,10 +1885,11 @@ export const EventPage = ({
</section>
) : 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 ? <RadioEventSection event={event} /> : 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 ? <RadioTeamSection {...radio} /> : null}

<section class="stream">
<h2>Watch</h2>
Expand Down
Loading