mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Store Twitch live streams in SQLite3 (#2738)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ab85afaeed
commit
a004cf33b7
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
14
app/features/live-streams/LiveStreamRepository.server.ts
Normal file
14
app/features/live-streams/LiveStreamRepository.server.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -77,6 +77,7 @@ export const testTournament = ({
|
|||
tieBreakerMapPool: [],
|
||||
toSetMapPool: [],
|
||||
participatedUsers: [],
|
||||
castStreams: [],
|
||||
mapPickingStyle: "AUTO_SZ",
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
|
|
|
|||
|
|
@ -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 }>()
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
41
app/routines/syncLiveStreams.ts
Normal file
41
app/routines/syncLiveStreams.ts
Normal 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);
|
||||
}
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
170
e2e/tournament-streams.spec.ts
Normal file
170
e2e/tournament-streams.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
21
migrations/114-live-streams.js
Normal file
21
migrations/114-live-streams.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user