From cda9c8b0825242232bc89394e38b2201e38f4ba1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 22 May 2023 00:43:51 +0300 Subject: [PATCH] Tournament streams initial --- .env.example | 3 + .../tournament-bracket/brackets-viewer.css | 14 +++ .../routes/to.$id.brackets.tsx | 2 +- .../tournament/core/streams.server.ts | 24 ++++ ...icipantTwitchUsersByTournamentId.server.ts | 25 ++++ .../tournament/routes/to.$id.streams.tsx | 84 +++++++++++++ app/features/tournament/routes/to.$id.tsx | 6 + app/features/tournament/tournament.css | 25 ++++ app/modules/twitch/index.ts | 1 + app/modules/twitch/schemas.ts | 34 ++++++ app/modules/twitch/streams.ts | 110 ++++++++++++++++++ app/modules/twitch/token.ts | 44 +++++++ app/modules/twitch/utils.ts | 9 ++ app/utils/urls.ts | 2 + remix.config.js | 4 + 15 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 app/features/tournament/core/streams.server.ts create mode 100644 app/features/tournament/queries/participantTwitchUsersByTournamentId.server.ts create mode 100644 app/features/tournament/routes/to.$id.streams.tsx create mode 100644 app/modules/twitch/index.ts create mode 100644 app/modules/twitch/schemas.ts create mode 100644 app/modules/twitch/streams.ts create mode 100644 app/modules/twitch/token.ts create mode 100644 app/modules/twitch/utils.ts diff --git a/.env.example b/.env.example index f99bc03ce..fb6880c2e 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,6 @@ STORAGE_SECRET= STORAGE_REGION= STORAGE_BUCKET= STORAGE_URL= + +TWITCH_CLIENT_ID= +TWITCH_CLIENT_SECRET= diff --git a/app/features/tournament-bracket/brackets-viewer.css b/app/features/tournament-bracket/brackets-viewer.css index 47ed0d7c6..3dfbf9718 100644 --- a/app/features/tournament-bracket/brackets-viewer.css +++ b/app/features/tournament-bracket/brackets-viewer.css @@ -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); } diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index c5ea00b09..11c481b4d 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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"}; } `; }); diff --git a/app/features/tournament/core/streams.server.ts b/app/features/tournament/core/streams.server.ts new file mode 100644 index 000000000..fd9a8ab40 --- /dev/null +++ b/app/features/tournament/core/streams.server.ts @@ -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; +} diff --git a/app/features/tournament/queries/participantTwitchUsersByTournamentId.server.ts b/app/features/tournament/queries/participantTwitchUsersByTournamentId.server.ts new file mode 100644 index 000000000..3b2c74774 --- /dev/null +++ b/app/features/tournament/queries/participantTwitchUsersByTournamentId.server.ts @@ -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>; +} diff --git a/app/features/tournament/routes/to.$id.streams.tsx b/app/features/tournament/routes/to.$id.streams.tsx new file mode 100644 index 000000000..c13f0bf7b --- /dev/null +++ b/app/features/tournament/routes/to.$id.streams.tsx @@ -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(); + const data = useLoaderData(); + + // TODO: or tournament has finalized + if (!parentRouteData.hasStarted) { + return ; + } + + if (data.streams.length === 0) { + return ( +
+ No live streams of this tournament available currently +
+ ); + } + + const thumbnailUrlToSrc = (url: string) => + url.replace("{width}", "640").replace("{height}", "360"); + + // TODO: link to user page, later tournament team page? + return ( +
+ {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 ( +
+ + + +
+
+ {user.discordName} + + {team.name} + +
+
+ + {stream.viewerCount} +
+
+
+ ); + })} +
+ ); +} diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index f04371df4..f0a139ad3 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -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() { {t("tournament:tabs.teams", { count: data.teams.length })} + {/* TODO: don't show when tournament finalized */} + {data.hasStarted ? ( + Streams ({data.streamsCount}) + ) : null} {canAdminTournament({ user, event: data.event }) && !data.hasStarted && Seeds} {canAdminTournament({ user, event: data.event }) && ( diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index b71c4eda6..009a54a22 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -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; diff --git a/app/modules/twitch/index.ts b/app/modules/twitch/index.ts new file mode 100644 index 000000000..c0073bb83 --- /dev/null +++ b/app/modules/twitch/index.ts @@ -0,0 +1 @@ +export { getStreams } from "./streams"; diff --git a/app/modules/twitch/schemas.ts b/app/modules/twitch/schemas.ts new file mode 100644 index 000000000..1fd2b06c1 --- /dev/null +++ b/app/modules/twitch/schemas.ts @@ -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; +export type RawStream = Unpacked["data"]>; diff --git a/app/modules/twitch/streams.ts b/app/modules/twitch/streams.ts new file mode 100644 index 000000000..e4ac497aa --- /dev/null +++ b/app/modules/twitch/streams.ts @@ -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 { + 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; +} diff --git a/app/modules/twitch/token.ts b/app/modules/twitch/token.ts new file mode 100644 index 000000000..f6dd855a7 --- /dev/null +++ b/app/modules/twitch/token.ts @@ -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"); +} diff --git a/app/modules/twitch/utils.ts b/app/modules/twitch/utils.ts new file mode 100644 index 000000000..2352f7eb4 --- /dev/null +++ b/app/modules/twitch/utils.ts @@ -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 }; +}; diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 84b2d5959..5ae9aa449 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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"; diff --git a/remix.config.js b/remix.config.js index 2bfc829ef..911edabd8 100644 --- a/remix.config.js +++ b/remix.config.js @@ -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",