mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-11 21:56:11 -05:00
Tournament streams initial
This commit is contained in:
@@ -18,3 +18,6 @@ STORAGE_SECRET=
|
||||
STORAGE_REGION=
|
||||
STORAGE_BUCKET=
|
||||
STORAGE_URL=
|
||||
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"};
|
||||
}
|
||||
`;
|
||||
});
|
||||
|
||||
24
app/features/tournament/core/streams.server.ts
Normal file
24
app/features/tournament/core/streams.server.ts
Normal 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;
|
||||
}
|
||||
@@ -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">>;
|
||||
}
|
||||
84
app/features/tournament/routes/to.$id.streams.tsx
Normal file
84
app/features/tournament/routes/to.$id.streams.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
app/modules/twitch/index.ts
Normal file
1
app/modules/twitch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getStreams } from "./streams";
|
||||
34
app/modules/twitch/schemas.ts
Normal file
34
app/modules/twitch/schemas.ts
Normal 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"]>;
|
||||
110
app/modules/twitch/streams.ts
Normal file
110
app/modules/twitch/streams.ts
Normal 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;
|
||||
}
|
||||
44
app/modules/twitch/token.ts
Normal file
44
app/modules/twitch/token.ts
Normal 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");
|
||||
}
|
||||
9
app/modules/twitch/utils.ts
Normal file
9
app/modules/twitch/utils.ts
Normal 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 };
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user