Tournament streams initial

This commit is contained in:
Kalle
2023-05-22 00:43:51 +03:00
parent cf8c1ff379
commit cda9c8b082
15 changed files with 386 additions and 1 deletions

View File

@@ -18,3 +18,6 @@ STORAGE_SECRET=
STORAGE_REGION=
STORAGE_BUCKET=
STORAGE_URL=
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=

View File

@@ -53,6 +53,20 @@
color: var(--text-lighter);
}
/** TODO: handle logic when to show */
.opponents::after {
display: none;
content: "🔴 Live";
position: absolute;
top: -11px;
right: 0;
background-color: var(--bg-light-variation);
color: var(--text-lighter);
font-size: 0.8em;
border-radius: 3px;
padding: 0 5px;
}
.bye {
color: var(--text-lighter);
}

View File

@@ -223,7 +223,7 @@ export default function TournamentBracketsPage() {
return /* css */ `
[data-participant-id="${participantId}"] {
--seed: "${i + 1} ";
--space-after-seed: ${i < 10 ? "6px" : "0px"};
--space-after-seed: ${i < 9 ? "6px" : "0px"};
}
`;
});

View File

@@ -0,0 +1,24 @@
import { getStreams } from "~/modules/twitch";
import { participantTwitchUsersByTournamentId } from "../queries/participantTwitchUsersByTournamentId.server";
export async function streamsByTournamentId(tournamentId: number) {
const twitchUsersOfTournament =
participantTwitchUsersByTournamentId(tournamentId);
const streams = await getStreams();
const tournamentStreams = streams.flatMap((stream) => {
const user = twitchUsersOfTournament.find(
(u) => u.twitch === stream.twitchUserName
);
if (!user) return [];
return {
...stream,
userId: user.id,
};
});
return tournamentStreams;
}

View File

@@ -0,0 +1,25 @@
import { sql } from "~/db/sql";
import type { User } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
"User"."id",
"User"."twitch"
from "User"
left join "TournamentTeamMember" on "TournamentTeamMember"."userId" = "User"."id"
left join "TournamentTeam" on "TournamentTeam"."id" = "TournamentTeamMember"."tournamentTeamId"
where "TournamentTeam"."tournamentId" = @tournamentId
and "User"."twitch" is not null
`);
// const testStm = sql.prepare(/* sql */ `
// select
// "User"."id",
// "User"."twitch"
// from "User"
// where "User"."twitch" is not null
// `);
export function participantTwitchUsersByTournamentId(tournamentId: number) {
return stm.all({ tournamentId }) as Array<Pick<User, "id" | "twitch">>;
}

View File

@@ -0,0 +1,84 @@
import type { LoaderArgs } from "@remix-run/node";
import { useLoaderData, useOutletContext } from "@remix-run/react";
import { streamsByTournamentId } from "../core/streams.server";
import { tournamentIdFromParams } from "../tournament-utils";
import type { TournamentLoaderData } from "./to.$id";
import { Avatar } from "~/components/Avatar";
import { Redirect } from "~/components/Redirect";
import { tournamentRegisterPage, twitchUrl } from "~/utils/urls";
import { UserIcon } from "~/components/icons/User";
export const loader = async ({ params }: LoaderArgs) => {
const tournamentId = tournamentIdFromParams(params);
return {
streams: await streamsByTournamentId(tournamentId),
};
};
export default function TournamentStreamsPage() {
const parentRouteData = useOutletContext<TournamentLoaderData>();
const data = useLoaderData<typeof loader>();
// TODO: or tournament has finalized
if (!parentRouteData.hasStarted) {
return <Redirect to={tournamentRegisterPage(parentRouteData.event.id)} />;
}
if (data.streams.length === 0) {
return (
<div className="text-center text-lg font-semi-bold text-lighter">
No live streams of this tournament available currently
</div>
);
}
const thumbnailUrlToSrc = (url: string) =>
url.replace("{width}", "640").replace("{height}", "360");
// TODO: link to user page, later tournament team page?
return (
<div className="stack horizontal lg flex-wrap justify-center">
{data.streams.flatMap((stream, i) => {
const team = parentRouteData.teams.find((team) =>
team.members.some((m) => m.userId === stream.userId)
);
const user = team?.members.find((m) => m.userId === stream.userId);
if (!team || !user) {
console.error("No team or user found for stream", stream);
return [];
}
return (
<div key={stream.userId} className="stack sm">
<a
href={twitchUrl(stream.twitchUserName)}
target="_blank"
rel="noreferrer"
>
<img
alt=""
src={thumbnailUrlToSrc(stream.thumbnailUrl)}
width={320}
height={180}
/>
</a>
<div className="stack horizontal justify-between">
<div className="tournament__stream__user-container">
<Avatar size="xxs" user={user} /> {user.discordName}
<span className="tournament__stream__team-name">
{team.name}
</span>
</div>
<div className="tournament__stream__viewer-count">
<UserIcon />
{stream.viewerCount}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -26,6 +26,7 @@ import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.serv
import { teamHasCheckedIn, tournamentIdFromParams } from "../tournament-utils";
import styles from "../tournament.css";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { streamsByTournamentId } from "../core/streams.server";
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
const wasMutation = args.formMethod === "post";
@@ -94,6 +95,7 @@ export const loader = async ({ params, request }: LoaderArgs) => {
teams: censorMapPools(teams),
mapListGeneratorAvailable,
hasStarted,
streamsCount: (await streamsByTournamentId(tournamentId)).length,
};
function censorMapPools(
@@ -138,6 +140,10 @@ export default function TournamentLayout() {
<SubNavLink to="teams">
{t("tournament:tabs.teams", { count: data.teams.length })}
</SubNavLink>
{/* TODO: don't show when tournament finalized */}
{data.hasStarted ? (
<SubNavLink to="streams">Streams ({data.streamsCount})</SubNavLink>
) : null}
{canAdminTournament({ user, event: data.event }) &&
!data.hasStarted && <SubNavLink to="seeds">Seeds</SubNavLink>}
{canAdminTournament({ user, event: data.event }) && (

View File

@@ -345,6 +345,31 @@
min-width: 2rem;
}
.tournament__stream__user-container {
font-size: var(--fonts-xs);
display: flex;
gap: var(--s-2);
align-items: center;
font-weight: var(--semi-bold);
}
.tournament__stream__team-name {
color: var(--theme-secondary);
}
.tournament__stream__viewer-count {
font-size: var(--fonts-xs);
display: flex;
gap: var(--s-2);
align-items: center;
margin-block-start: -5px;
color: var(--text-lighter);
}
.tournament__stream__viewer-count > svg {
width: 0.75rem;
}
@media screen and (min-width: 640px) {
.tournament__section {
margin: 0;

View File

@@ -0,0 +1 @@
export { getStreams } from "./streams";

View File

@@ -0,0 +1,34 @@
import { z } from "zod";
import type { Unpacked } from "~/utils/types";
export const streamsSchema = z.object({
data: z.array(
z.object({
id: z.string(),
user_id: z.string(),
user_login: z.string(),
user_name: z.string(),
game_id: z.string(),
game_name: z.string(),
type: z.string(),
title: z.string(),
viewer_count: z.number(),
started_at: z.string(),
language: z.string(),
thumbnail_url: z.string(),
tag_ids: z.array(z.unknown()),
tags: z.array(z.string()).nullish(),
is_mature: z.boolean(),
})
),
pagination: z.object({ cursor: z.string().nullish() }),
});
export const tokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
token_type: z.string(),
});
export type StreamsResponse = z.infer<typeof streamsSchema>;
export type RawStream = Unpacked<z.infer<typeof streamsSchema>["data"]>;

View File

@@ -0,0 +1,110 @@
import { cachified } from "cachified";
import { type StreamsResponse, type RawStream, streamsSchema } from "./schemas";
import { getToken, purgeCachedToken } from "./token";
import { getTwitchEnvVars } from "./utils";
import { cache } from "~/utils/cache.server";
export async function getStreams() {
try {
const result = await cachified({
key: `twitch-streams`,
cache,
// 5 minutes
ttl: 1000 * 60 * 5,
// 10 minutes
staleWhileRevalidate: 1000 * 60 * 5 * 2,
async getFreshValue() {
// eslint-disable-next-line no-console
console.log("getting fresh twitch streams");
return (await getAllStreams())
.map(mapRawStream)
.sort((a, b) => b.viewerCount - a.viewerCount);
},
});
return result;
} catch (e) {
console.error(e);
return [];
}
}
function mapRawStream(stream: RawStream) {
return {
thumbnailUrl: stream.thumbnail_url,
twitchUserName: stream.user_login,
viewerCount: stream.viewer_count,
};
}
const SPLATOON_3_TWITCH_GAME_ID = "1158884259";
async function getAllStreams() {
const result: RawStream[] = [];
let cursor: string | undefined = undefined;
let count = 0;
while (true) {
if (count === 50) {
throw new Error("Stuck getting streams");
}
const { data, pagination } = await getStreamsChunk({ cursor });
result.push(
// filter to ensure each streamer appears only once
...data.filter(
(stream) =>
!result.some(
(existingStream) => existingStream.user_id === stream.user_id
)
)
);
if (!pagination.cursor) {
return result;
}
cursor = pagination.cursor;
count++;
}
}
export async function getStreamsChunk({
isRetry = false,
cursor,
}: {
isRetry?: boolean;
cursor?: string;
}): Promise<StreamsResponse> {
const { TWITCH_CLIENT_ID } = getTwitchEnvVars();
const token = await getToken();
const res = await fetch(
`https://api.twitch.tv/helix/streams?game_id=${SPLATOON_3_TWITCH_GAME_ID}&first=100&after=${
cursor ?? ""
}`,
{
headers: [
["Authorization", `Bearer ${token}`],
["Client-Id", TWITCH_CLIENT_ID],
],
}
);
if (res.status === 401 && !isRetry) {
purgeCachedToken();
return getStreamsChunk({ isRetry: true, cursor });
}
if (!res.ok) {
throw new Error(
`Getting Twitch token failed with status code: ${res.status}`
);
}
const parsed = streamsSchema.safeParse(await res.json());
if (!parsed.success) {
throw new Error(parsed.error.message);
}
return parsed.data;
}

View File

@@ -0,0 +1,44 @@
import { cachified } from "cachified";
import { cache } from "~/utils/cache.server";
import { tokenResponseSchema } from "./schemas";
import { getTwitchEnvVars } from "./utils";
async function getFreshToken() {
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = getTwitchEnvVars();
const res = await fetch(
"https://id.twitch.tv/oauth2/token" +
`?client_id=${TWITCH_CLIENT_ID}` +
`&client_secret=${TWITCH_CLIENT_SECRET}` +
"&grant_type=client_credentials",
{ method: "POST" }
);
if (!res.ok) {
throw new Error(
`Getting Twitch token failed with status code: ${res.status}`
);
}
const parsed = tokenResponseSchema.safeParse(await res.json());
if (!parsed.success) {
throw new Error("Token response schema validation failed");
}
return parsed.data.access_token;
}
export function getToken() {
return cachified({
key: `twitch-token`,
cache,
getFreshValue() {
// eslint-disable-next-line no-console
console.log("getting fresh twitch token");
return getFreshToken();
},
});
}
export function purgeCachedToken() {
cache.delete("twitch-token");
}

View File

@@ -0,0 +1,9 @@
import invariant from "tiny-invariant";
export const getTwitchEnvVars = () => {
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = process.env;
invariant(TWITCH_CLIENT_ID, "Missing TWITCH_CLIENT_ID env var");
invariant(TWITCH_CLIENT_SECRET, "Missing TWITCH_CLIENT_SECRET env var");
return { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET };
};

View File

@@ -60,6 +60,8 @@ export const SPLATOON_3_INK = "https://splatoon3.ink/";
export const twitterUrl = (accountName: string) =>
`https://twitter.com/${accountName}`;
export const twitchUrl = (accountName: string) =>
`https://twitch.tv/${accountName}`;
export const LOG_IN_URL = "/auth";
export const LOG_OUT_URL = "/auth/logout";

View File

@@ -25,6 +25,10 @@ module.exports = {
route("/to/:id/admin", "features/tournament/routes/to.$id.admin.tsx");
route("/to/:id/seeds", "features/tournament/routes/to.$id.seeds.tsx");
route("/to/:id/maps", "features/tournament/routes/to.$id.maps.tsx");
route(
"/to/:id/streams",
"features/tournament/routes/to.$id.streams.tsx"
);
route(
"/to/:id/brackets",