Store Twitch live streams in SQLite3 (#2738)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kalle 2026-01-18 16:51:44 +02:00 committed by GitHub
parent ab85afaeed
commit a004cf33b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 4292 additions and 110 deletions

View File

@ -1,5 +1,6 @@
import { faker } from "@faker-js/faker";
import { add, sub } from "date-fns";
import { nanoid } from "nanoid";
import * as R from "remeda";
import { db, sql } from "~/db/sql";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
@ -238,6 +239,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
variation === "NO_SCRIMS" ? undefined : scrimPostRequests,
associations,
notifications,
liveStreams,
];
export async function seed(variation?: SeedVariation | null) {
@ -303,6 +305,7 @@ function wipeDB() {
"BadgeManager",
"TournamentOrganization",
"SeedingSkill",
"LiveStream",
];
for (const table of tablesToDelete) {
@ -2749,3 +2752,70 @@ async function organization() {
badges: [],
});
}
function liveStreams() {
const userIds = userIdsInAscendingOrderById();
// Add deterministic streams for E2E testing
// Users 6 and 7 are in ITZ tournament team 102
const deterministicStreams = [
{ userId: 6, viewerCount: 150, twitch: "test_player_stream_1" },
{ userId: 7, viewerCount: 75, twitch: "test_player_stream_2" },
// Cast-only stream (user 100 is not in ITZ tournament teams)
{ userId: 100, viewerCount: 500, twitch: "test_cast_stream" },
];
for (const stream of deterministicStreams) {
sql
.prepare(
`
insert into "LiveStream" ("userId", "viewerCount", "thumbnailUrl", "twitch")
values ($userId, $viewerCount, $thumbnailUrl, $twitch)
`,
)
.run({
userId: stream.userId,
viewerCount: stream.viewerCount,
thumbnailUrl: "https://picsum.photos/320/180",
twitch: stream.twitch,
});
}
const streamingUserIds = [
...userIds.slice(3, 20),
...userIds.slice(40, 50),
...userIds.slice(100, 110),
].filter((id) => !deterministicStreams.some((s) => s.userId === id));
const shuffledStreamers = faker.helpers.shuffle(streamingUserIds);
const selectedStreamers = shuffledStreamers.slice(0, 17);
for (const userId of selectedStreamers) {
const viewerCount = faker.helpers.weightedArrayElement([
{ value: faker.number.int({ min: 5, max: 30 }), weight: 5 },
{ value: faker.number.int({ min: 31, max: 100 }), weight: 3 },
{ value: faker.number.int({ min: 101, max: 500 }), weight: 2 },
{ value: faker.number.int({ min: 501, max: 2000 }), weight: 1 },
]);
const thumbnailUrl = faker.image.urlPicsumPhotos({
width: 320,
height: 180,
});
const twitch = `fake_${nanoid()}`.toLowerCase();
sql
.prepare(
`
insert into "LiveStream" ("userId", "viewerCount", "thumbnailUrl", "twitch")
values ($userId, $viewerCount, $thumbnailUrl, $twitch)
`,
)
.run({
userId,
viewerCount,
thumbnailUrl,
twitch,
});
}
}

View File

@ -962,6 +962,14 @@ export interface ApiToken {
createdAt: GeneratedAlways<number>;
}
export interface LiveStream {
id: GeneratedAlways<number>;
userId: number | null;
viewerCount: number;
thumbnailUrl: string;
twitch: string | null;
}
export interface BanLog {
id: GeneratedAlways<number>;
userId: number;
@ -1136,6 +1144,7 @@ export interface DB {
AllTeamMember: TeamMember;
ApiToken: ApiToken;
Art: Art;
LiveStream: LiveStream;
ArtTag: ArtTag;
ArtUserMetadata: ArtUserMetadata;
TaggedArt: TaggedArt;

View File

@ -9,7 +9,12 @@ import { type EntryContext, ServerRouter } from "react-router";
import { config } from "~/modules/i18n/config"; // your i18n configuration file
import { i18next } from "~/modules/i18n/i18next.server";
import { resources } from "./modules/i18n/resources.server";
import { daily, everyHourAt00, everyHourAt30 } from "./routines/list.server";
import {
daily,
everyHourAt00,
everyHourAt30,
everyTwoMinutes,
} from "./routines/list.server";
import { logger } from "./utils/logger";
// Reject/cancel all pending promises after 5 seconds
@ -103,6 +108,12 @@ if (!global.appStartSignal && process.env.NODE_ENV === "production") {
await routine.run();
}
});
cron.schedule("*/2 * * * *", async () => {
for (const routine of everyTwoMinutes) {
await routine.run();
}
});
}
process.on("unhandledRejection", (reason: string, p: Promise<any>) => {

View File

@ -0,0 +1,14 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
export function replaceAll(
streams: Omit<TablesInsertable["LiveStream"], "id">[],
) {
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("LiveStream").execute();
if (streams.length > 0) {
await trx.insertInto("LiveStream").values(streams).execute();
}
});
}

View File

@ -1,17 +1,13 @@
import clsx from "clsx";
import { differenceInMinutes } from "date-fns";
import * as React from "react";
import { Link, useFetcher } from "react-router";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { useUser } from "~/features/auth/core/user";
import { TournamentStream } from "~/features/tournament/components/TournamentStream";
import type { TournamentStreamsLoader } from "~/features/tournament/loaders/to.$id.streams.server";
import {
useStreamingParticipants,
useTournament,
} from "~/features/tournament/routes/to.$id";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { databaseTimestampToDate } from "~/utils/dates";
import type { Unpacked } from "~/utils/types";
import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls";
@ -55,7 +51,7 @@ export function Match(props: MatchProps) {
function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
const tournament = useTournament();
const streamingParticipants = useStreamingParticipants();
const streamingParticipants = tournament.streamingParticipantIds ?? [];
const prefix = () => {
if (type === "winners") return "WB ";
@ -244,19 +240,10 @@ function MatchRow({
function MatchStreams({ match }: Pick<MatchProps, "match">) {
const tournament = useTournament();
const fetcher = useFetcher<TournamentStreamsLoader>();
React.useEffect(() => {
if (fetcher.state !== "idle" || fetcher.data) return;
fetcher.load(`/to/${tournament.ctx.id}/streams`);
}, [fetcher, tournament.ctx.id]);
if (!fetcher.data || !match.opponent1?.id || !match.opponent2?.id)
return (
<div className="text-lighter text-center tournament-bracket__stream-popover">
Loading streams...
</div>
);
if (!match.opponent1?.id || !match.opponent2?.id) {
return null;
}
const castingAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find(
(cm) => cm.matchId === match.id,
@ -266,13 +253,13 @@ function MatchStreams({ match }: Pick<MatchProps, "match">) {
(teamId) => tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [],
);
const streamsOfThisMatch = fetcher.data.streams.filter(
const streamsOfThisMatch = tournament.streams.filter(
(stream) =>
(stream.userId && matchParticipants.includes(stream.userId)) ||
stream.twitchUserName === castingAccount,
);
if (streamsOfThisMatch.length === 0)
if (streamsOfThisMatch.length === 0) {
return (
<div className="tournament-bracket__stream-popover">
After all there seems to be no streams of this match. Check the{" "}
@ -280,9 +267,13 @@ function MatchStreams({ match }: Pick<MatchProps, "match">) {
for all the available streams.
</div>
);
}
return (
<div className="stack md justify-center tournament-bracket__stream-popover">
<div
className="stack md justify-center tournament-bracket__stream-popover"
data-testid="stream-popover"
>
{streamsOfThisMatch.map((stream) => (
<TournamentStream
key={stream.twitchUserName}

View File

@ -1382,4 +1382,33 @@ export class Tournament {
staff.id === user.id && ["ORGANIZER", "STREAMER"].includes(staff.role),
);
}
get streams() {
const memberStreams = this.ctx.teams
.filter((team) => team.checkIns.length > 0)
.flatMap((team) => team.members)
.filter((member) => member.streamTwitch)
.map((member) => ({
thumbnailUrl: member.streamThumbnailUrl!,
twitchUserName: member.streamTwitch!,
viewerCount: member.streamViewerCount!,
userId: member.userId,
}));
const castStreams = this.ctx.castStreams.map((stream) => ({
thumbnailUrl: stream.thumbnailUrl,
twitchUserName: stream.twitch!,
viewerCount: stream.viewerCount,
userId: null as number | null,
}));
return [...memberStreams, ...castStreams].sort(
(a, b) => b.viewerCount - a.viewerCount,
);
}
get streamingParticipantIds(): number[] {
if (!this.hasStarted || this.everyBracketOver) return [];
return this.streams.filter((s) => s.userId !== null).map((s) => s.userId!);
}
}

View File

@ -46,6 +46,9 @@ describe("tournamentSummary()", () => {
plusTier: null,
createdAt: 0,
userId,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
})),
name: `Team ${teamId}`,
prefersNotToHost: 0,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -412,6 +412,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734656039,
inGameName: "Plussy#1291",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 2899,
@ -425,6 +428,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734656044,
inGameName: "CHIMERA#1263",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 6114,
@ -438,6 +444,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734656047,
inGameName: "CountMeOut#1985",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 33963,
@ -451,6 +460,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734664082,
inGameName: "BIDOOFGMAX#8251",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 30176,
@ -464,6 +476,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734674285,
inGameName: "Bugha 33#1316",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
],
checkIns: [
@ -506,6 +521,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734423187,
inGameName: "☆ SD-J ☆#2947",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 21689,
@ -519,6 +537,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734424893,
inGameName: "parasyka#2169",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 3147,
@ -532,6 +553,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734426984,
inGameName: "cookie♪#1006",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 2072,
@ -545,6 +569,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734426986,
inGameName: null,
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
],
checkIns: [
@ -582,6 +609,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734660846,
inGameName: "Telethia#6611",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 13370,
@ -595,6 +625,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734660856,
inGameName: "Puma#2209",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 45,
@ -608,6 +641,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734660882,
inGameName: "ShockWavee#3003",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 1843,
@ -621,6 +657,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734663143,
inGameName: null,
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
],
checkIns: [
@ -658,6 +697,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734683349,
inGameName: "mitsi#2589",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 13590,
@ -671,6 +713,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734683352,
inGameName: "☆ SD-N ☆#2936",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 10757,
@ -684,6 +729,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734683356,
inGameName: "Wilds ♪#6274",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 33047,
@ -697,6 +745,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734683966,
inGameName: "2F Law#1355",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 41024,
@ -710,6 +761,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734685180,
inGameName: "His Silly#2385",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
],
checkIns: [
@ -752,6 +806,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734608907,
inGameName: "H! Veems#3106",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 29665,
@ -765,6 +822,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734608923,
inGameName: "H!PwPwPew#2889",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 46006,
@ -778,6 +838,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734608925,
inGameName: "H!Ozzysqid#2558",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 33483,
@ -791,6 +854,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734608931,
inGameName: "DrkXWolf17#3326",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 11780,
@ -804,6 +870,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734659216,
inGameName: "Slanted#1646",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 37901,
@ -817,6 +886,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734684084,
inGameName: null,
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
],
checkIns: [
@ -854,6 +926,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734397954,
inGameName: "Albonchap#9998",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 43662,
@ -867,6 +942,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734397970,
inGameName: "FoolLime#1864",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 33491,
@ -880,6 +958,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734397973,
inGameName: "snowy#2709",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 46467,
@ -893,6 +974,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734398287,
inGameName: "Veryneggy#1494",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 46813,
@ -906,6 +990,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734398628,
inGameName: "Mikil#2961",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
],
checkIns: [
@ -948,6 +1035,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734598652,
inGameName: "ЯR Dit-toe#3315",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 33611,
@ -961,6 +1051,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734598655,
inGameName: "ЯR Samkat #3138",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 31148,
@ -974,6 +1067,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734598656,
inGameName: "ЯR smart!!#1424",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
{
userId: 33578,
@ -987,6 +1083,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
createdAt: 1734612388,
inGameName: "Mat#1561",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
},
],
checkIns: [
@ -1105,5 +1204,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
17855, 21689, 26992, 30176, 31148, 33047, 33491, 33578, 33611, 37632,
37901, 43518, 43662, 45879, 46006, 46467, 46813,
],
castStreams: [],
},
});

File diff suppressed because it is too large Load Diff

View File

@ -77,6 +77,7 @@ export const testTournament = ({
tieBreakerMapPool: [],
toSetMapPool: [],
participatedUsers: [],
castStreams: [],
mapPickingStyle: "AUTO_SZ",
settings: {
bracketProgression: [

View File

@ -177,6 +177,7 @@ export async function findById(id: number) {
),
)
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin("LiveStream", "LiveStream.userId", "User.id")
.select([
"User.id as userId",
"User.username",
@ -193,6 +194,9 @@ export async function findById(id: number) {
"TournamentTeamMember"."inGameName",
"User"."inGameName"
)`.as("inGameName"),
"LiveStream.twitch as streamTwitch",
"LiveStream.viewerCount as streamViewerCount",
"LiveStream.thumbnailUrl as streamThumbnailUrl",
])
.whereRef(
"TournamentTeamMember.tournamentTeamId",
@ -286,6 +290,18 @@ export async function findById(id: number) {
.groupBy("TournamentMatchGameResultParticipant.userId")
.where("TournamentStage.tournamentId", "=", id),
).as("participatedUsers"),
jsonArrayFrom(
eb
.selectFrom("LiveStream")
.select([
"LiveStream.twitch",
"LiveStream.viewerCount",
"LiveStream.thumbnailUrl",
])
.where(
sql<boolean>`"LiveStream"."twitch" IN (SELECT value FROM json_each("Tournament"."castTwitchAccounts"))`,
),
).as("castStreams"),
])
.where("Tournament.id", "=", id)
.$narrowType<{ author: NotNull }>()

View File

@ -1,16 +1,15 @@
import { Avatar } from "~/components/Avatar";
import { UserIcon } from "~/components/icons/User";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
import type { SerializeFrom } from "~/utils/remix";
import { twitchUrl } from "~/utils/urls";
import type { TournamentStreamsLoader } from "../loaders/to.$id.streams.server";
import { useTournament } from "../routes/to.$id";
export function TournamentStream({
stream,
withThumbnail = true,
}: {
stream: SerializeFrom<TournamentStreamsLoader>["streams"][number];
stream: Tournament["streams"][number];
withThumbnail?: boolean;
}) {
const tournament = useTournament();
@ -20,7 +19,11 @@ export function TournamentStream({
const user = team?.members.find((m) => m.userId === stream.userId);
return (
<div key={stream.userId} className="stack sm">
<div
key={stream.userId}
className="stack sm"
data-testid="tournament-stream"
>
{withThumbnail ? (
<a
href={twitchUrl(stream.twitchUserName)}

View File

@ -1,43 +0,0 @@
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import { getStreams } from "~/modules/twitch";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
export async function streamsByTournamentId(tournament: TournamentData["ctx"]) {
// prevent error logs in development
if (
(process.env.NODE_ENV === "development" && !process.env.TWITCH_CLIENT_ID) ||
IS_E2E_TEST_RUN
) {
return [];
}
const twitchUsersOfTournament = tournament.teams
.filter((team) => team.checkIns.length > 0)
.flatMap((team) => team.members)
.filter((member) => member.twitch);
const streams = await getStreams();
const tournamentStreams = streams.flatMap((stream) => {
const member = twitchUsersOfTournament.find(
(member) => member.twitch === stream.twitchUserName,
);
if (member) {
return {
...stream,
userId: member.userId,
};
}
if (tournament.castTwitchAccounts?.includes(stream.twitchUserName)) {
return {
...stream,
userId: null,
};
}
return [];
});
return tournamentStreams;
}

View File

@ -5,7 +5,6 @@ import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournam
import { databaseTimestampToDate } from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { streamsByTournamentId } from "../core/streams.server";
export type TournamentLoaderData = {
tournament: Awaited<ReturnType<typeof tournamentDataCached>>;
@ -28,11 +27,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const tournament = await tournamentDataCached({ tournamentId, user });
const streams =
tournament.data.stage.length > 0 && !tournament.ctx.isFinalized
? await streamsByTournamentId(tournament.ctx)
: [];
const tournamentStartedInTheLastMonth =
databaseTimestampToDate(tournament.ctx.startTime) >
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
@ -58,8 +52,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
// skip expensive rr7 data serialization (hot path loader)
return JSON.stringify({
tournament,
streamingParticipants: streams.flatMap((s) => (s.userId ? [s.userId] : [])),
streamsCount: streams.length,
friendCodes: showFriendCodes
? await TournamentRepository.friendCodesByTournamentId(tournamentId)
: undefined,

View File

@ -1,19 +0,0 @@
import type { LoaderFunctionArgs } from "react-router";
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { streamsByTournamentId } from "../core/streams.server";
export type TournamentStreamsLoader = typeof loader;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
const tournament = notFoundIfFalsy(await tournamentData({ tournamentId }));
return {
streams: await streamsByTournamentId(tournament.ctx),
};
};

View File

@ -1,22 +1,18 @@
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { Redirect } from "~/components/Redirect";
import { tournamentRegisterPage } from "~/utils/urls";
import { TournamentStream } from "../components/TournamentStream";
import { loader } from "../loaders/to.$id.streams.server";
import { useTournament } from "./to.$id";
export { loader };
export default function TournamentStreamsPage() {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
if (!tournament.hasStarted || tournament.everyBracketOver) {
return <Redirect to={tournamentRegisterPage(tournament.ctx.id)} />;
}
if (data.streams.length === 0) {
if (tournament.streams.length === 0) {
return (
<div className="text-center text-lg font-semi-bold text-lighter">
{t("tournament:streams.none")}
@ -27,7 +23,7 @@ export default function TournamentStreamsPage() {
// TODO: link to user page, later tournament team page?
return (
<div className="stack horizontal lg flex-wrap justify-center">
{data.streams.map((stream) => (
{tournament.streams.map((stream) => (
<TournamentStream key={stream.twitchUserName} stream={stream} />
))}
</div>

View File

@ -205,7 +205,7 @@ export function TournamentLayout() {
{tournament.hasStarted && !tournament.everyBracketOver ? (
<SubNavLink to="streams">
{t("tournament:tabs.streams", {
count: data.streamsCount,
count: tournament.streams.length,
})}
</SubNavLink>
) : null}
@ -234,7 +234,6 @@ export function TournamentLayout() {
tournament,
bracketExpanded,
setBracketExpanded,
streamingParticipants: data.streamingParticipants,
friendCodes: data.friendCodes,
preparedMaps: data.preparedMaps,
} satisfies TournamentContext
@ -248,7 +247,6 @@ export function TournamentLayout() {
type TournamentContext = {
tournament: Tournament;
bracketExpanded: boolean;
streamingParticipants: number[];
setBracketExpanded: (expanded: boolean) => void;
friendCode?: string;
friendCodes?: TournamentLoaderData["friendCodes"];
@ -266,10 +264,6 @@ export function useBracketExpanded() {
return { bracketExpanded, setBracketExpanded };
}
export function useStreamingParticipants() {
return useOutletContext<TournamentContext>().streamingParticipants;
}
export function useTournamentFriendCodes() {
return useOutletContext<TournamentContext>().friendCodes;
}

View File

@ -1124,3 +1124,13 @@ export async function anyUserPrefersNoScreen(
return Boolean(result);
}
export function findIdsByTwitchUsernames(twitchUsernames: string[]) {
if (twitchUsernames.length === 0) return [];
return db
.selectFrom("User")
.select(["User.id", "User.twitch"])
.where("User.twitch", "in", twitchUsernames)
.execute();
}

View File

@ -1,5 +1,10 @@
import invariant from "~/utils/invariant";
export const hasTwitchEnvVars = () => {
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = process.env;
return Boolean(TWITCH_CLIENT_ID && TWITCH_CLIENT_SECRET);
};
export const getTwitchEnvVars = () => {
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = process.env;
invariant(

View File

@ -6,6 +6,7 @@ import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting";
import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon";
import { NotifySeasonStartRoutine } from "./notifySeasonStart";
import { SetOldGroupsAsInactiveRoutine } from "./setOldGroupsAsInactive";
import { SyncLiveStreamsRoutine } from "./syncLiveStreams";
import { UpdatePatreonDataRoutine } from "./updatePatreonData";
/** List of Routines that should occur hourly at XX:00 */
@ -28,3 +29,6 @@ export const daily = [
DeleteOldNotificationsRoutine,
CloseExpiredCommissionsRoutine,
];
/** List of Routines that should occur every 2 minutes */
export const everyTwoMinutes = [SyncLiveStreamsRoutine];

View File

@ -0,0 +1,41 @@
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { getStreams } from "~/modules/twitch";
import { hasTwitchEnvVars } from "~/modules/twitch/utils";
import { Routine } from "./routine.server";
export const SyncLiveStreamsRoutine = new Routine({
name: "SyncLiveStreams",
func: syncLiveStreams,
});
async function syncLiveStreams() {
if (!hasTwitchEnvVars()) return;
const streams = await getStreams();
if (streams.length === 0) {
await LiveStreamRepository.replaceAll([]);
return;
}
const streamTwitchNames = streams.map((s) => s.twitchUserName);
const matchingUsers =
await UserRepository.findIdsByTwitchUsernames(streamTwitchNames);
const twitchToUserId = new Map<string, number>();
for (const user of matchingUsers) {
if (user.twitch) {
twitchToUserId.set(user.twitch, user.id);
}
}
const liveStreams = streams.map((stream) => ({
userId: twitchToUserId.get(stream.twitchUserName) ?? null,
viewerCount: stream.viewerCount,
thumbnailUrl: stream.thumbnailUrl,
twitch: stream.twitchUserName,
}));
await LiveStreamRepository.replaceAll(liveStreams);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,170 @@
import type { Page } from "@playwright/test";
import {
expect,
impersonate,
navigate,
seed,
startBracket,
submit,
test,
} from "~/utils/playwright";
import {
tournamentAdminPage,
tournamentBracketsPage,
tournamentStreamsPage,
} from "~/utils/urls";
const navigateToMatch = async (page: Page, matchId: number) => {
await expect(async () => {
await page.locator(`[data-match-id="${matchId}"]`).click();
await expect(page.getByTestId("match-header")).toBeVisible();
}).toPass();
};
const selectRosterIfNeeded = async (page: Page, teamIndex: 0 | 1) => {
const position = teamIndex === 0 ? "first" : "last";
const checkbox = page.getByTestId("player-checkbox-0")[position]();
if ((await checkbox.count()) > 0 && !(await checkbox.isDisabled())) {
await page.getByTestId("player-checkbox-0")[position]().click();
await page.getByTestId("player-checkbox-1")[position]().click();
await page.getByTestId("player-checkbox-2")[position]().click();
await page.getByTestId("player-checkbox-3")[position]().click();
await submit(page, `save-active-roster-button-${teamIndex}`);
await expect(
page.getByTestId("player-checkbox-0")[position](),
).toBeDisabled();
}
};
const reportPartialScore = async (page: Page) => {
await page.getByTestId("actions-tab").click();
await selectRosterIfNeeded(page, 0);
await selectRosterIfNeeded(page, 1);
await page.getByTestId("winner-radio-1").click();
await submit(page, "report-score-button");
await expect(page.getByText("1-0")).toBeVisible();
};
const backToBracket = async (page: Page) => {
await page.getByTestId("back-to-bracket-button").click();
await expect(page.getByTestId("brackets-viewer")).toBeVisible();
};
test.describe("Tournament streams", () => {
test("can set cast twitch accounts in admin", async ({ page }) => {
const tournamentId = 2;
await seed(page);
await impersonate(page);
await navigate({
page,
url: tournamentAdminPage(tournamentId),
});
await page
.getByLabel("Twitch accounts")
.fill("test_cast_stream,another_cast");
await submit(page, "save-cast-twitch-accounts-button");
// Verify persistence by navigating away and back
await navigate({
page,
url: tournamentBracketsPage({ tournamentId }),
});
await navigate({
page,
url: tournamentAdminPage(tournamentId),
});
await expect(page.getByLabel("Twitch accounts")).toHaveValue(
"test_cast_stream,another_cast",
);
});
test("can view streams on bracket popover when match is in progress", async ({
page,
}) => {
const tournamentId = 2;
// Match 2 is team 102 (seed 2) vs team 115 (seed 15)
// Team 102 has users 6 and 7 who have deterministic streams
const matchId = 2;
await startBracket(page, tournamentId);
await navigateToMatch(page, matchId);
// Report partial score to set startedAt (match becomes "in progress")
await reportPartialScore(page);
await backToBracket(page);
// The LIVE button should be visible since team 102 members are streaming
const liveButton = page.getByText("LIVE").first();
await expect(liveButton).toBeVisible();
// Click the LIVE button to open the popover
await liveButton.click();
// Verify stream popover shows the streaming player info
await expect(page.getByTestId("stream-popover")).toBeVisible();
// Multiple streams may be visible, verify at least one exists
await expect(page.getByTestId("tournament-stream").first()).toBeVisible();
});
test("can view streams on streams page", async ({ page }) => {
const tournamentId = 2;
await startBracket(page, tournamentId);
await navigate({
page,
url: tournamentStreamsPage(tournamentId),
});
// Verify TournamentStream components are visible
const streams = page.getByTestId("tournament-stream");
await expect(streams.first()).toBeVisible();
// Verify stream info is displayed (viewer count)
await expect(page.locator("text=150").first()).toBeVisible();
});
test("cast stream shows on bracket when match is set as casted", async ({
page,
}) => {
const tournamentId = 2;
// Match 2 involves team 102 which has 4 players (no roster selection needed)
const matchId = 2;
await seed(page);
await impersonate(page);
// Set up cast twitch account (test_cast_stream exists as live stream in seed)
await navigate({
page,
url: tournamentAdminPage(tournamentId),
});
await page.getByLabel("Twitch accounts").fill("test_cast_stream");
await submit(page, "save-cast-twitch-accounts-button");
// Start bracket
await navigate({
page,
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("finalize-bracket-button").click();
await submit(page, "confirm-finalize-bracket-button");
// Navigate to match and start it
await navigateToMatch(page, matchId);
await reportPartialScore(page);
// Set match as casted
await page.getByTestId("cast-info-select").selectOption("test_cast_stream");
await submit(page, "cast-info-submit-button");
await backToBracket(page);
// Verify LIVE button appears (multiple may exist from player streams)
await expect(page.getByText("LIVE").first()).toBeVisible();
});
});

View File

@ -0,0 +1,21 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/*sql*/ `
create table "LiveStream" (
"id" integer primary key,
"userId" integer unique,
"viewerCount" integer not null,
"thumbnailUrl" text not null,
"twitch" text,
foreign key ("userId") references "User"("id") on delete cascade
) strict
`,
).run();
db.prepare(/*sql*/ `create index user_twitch on "User"("twitch")`).run();
db.prepare(
/*sql*/ `create index livestream_twitch on "LiveStream"("twitch")`,
).run();
})();
}