Tournament auto vods & censor mode (#2933)

This commit is contained in:
Kalle
2026-04-02 17:29:58 +03:00
committed by GitHub
parent 78f3720395
commit 1e8cccb800
84 changed files with 1965 additions and 113 deletions

View File

@@ -1,9 +1,9 @@
---
name: sendou-code-review
description: Multi-agent code review that checks the current diff from 6 angles (spec compliance, modernization, bugs, CLAUDE.md rules, abstraction reuse, security) and produces a unified review. Works on branch diffs vs main or staged changes.
description: Multi-agent code review that checks the current diff from 7 angles (spec compliance, modernization, bugs, CLAUDE.md rules, abstraction reuse, security, DB query performance) and produces a unified review. Works on branch diffs vs main or staged changes.
---
Review the current code changes from multiple angles using parallel sub-agents, then synthesize into a single high-quality review.
Review the current code changes from multiple angles using parallel sub-agents, then synthesize into a single high-quality review. If the diff contains no changes to Repository files or SQL/Kysely code, skip Agent 7 (DB Query Performance).
## Step 1: Determine what to review
@@ -25,7 +25,7 @@ Also run these in parallel:
The user may have provided a GitHub issue URL or description of what the code should do as an argument. If they did, this will be used by the Spec Compliance agent. If not, the Spec Compliance agent will be skipped.
## Step 3: Launch 6 parallel review agents
## Step 3: Launch up to 7 parallel review agents
Launch these as parallel Agent calls. Each agent receives:
- The full diff
@@ -183,12 +183,57 @@ Focus on real, exploitable vulnerabilities in the new code. Do not flag:
Return a list of vulnerabilities, each with: file path, vulnerability type (e.g., "SQL Injection"), description, attack scenario, and suggested fix.
```
### Agent 7: DB Query Performance (skip if no Repository/Kysely changes in diff)
```
You are reviewing code changes for database query performance. This is a Remix/React Router web app using SQLite via Kysely. The dev database is at `db.sqlite3`.
Here is the diff:
{diff}
Changed files: {file_list}
Your job:
1. **Identify new or changed DB queries** in the diff. These live in `*Repository.server.ts` files and use Kysely. Read the full changed Repository files for context.
2. **For each query**, do the following:
a. **Run EXPLAIN QUERY PLAN** against the dev database (`db.sqlite3`) using the Bash tool:
```
sqlite3 db.sqlite3 "EXPLAIN QUERY PLAN <the SQL query>"
```
To get the raw SQL from Kysely, read the query and mentally compile it. Substitute realistic placeholder values for any parameters.
b. **Check for missing indexes**: Look at the EXPLAIN output for "SCAN TABLE" (full table scan) vs "SEARCH TABLE ... USING INDEX" or "USING COVERING INDEX". A SCAN on a large table in a hot path is a red flag.
c. **Check existing indexes**: Run `sqlite3 db.sqlite3 ".indexes <table_name>"` and `sqlite3 db.sqlite3 "PRAGMA index_info(<index_name>)"` to see what indexes exist.
d. **Assess query context** — reason about:
- **Table size**: Is this a table with thousands/millions of rows (e.g., SplatoonPlayer, Build, GroupMatch) or a small config-like table (e.g., CalendarEventTag, TournamentBadgeOwner)?
- **Call frequency**: Is this query in a hot path (page loader hit on every page view, API called frequently) or a cold path (admin action, background routine, rare user action)?
- **N+1 patterns**: Is the query called inside a loop when it could be batched?
Use the route file or caller to determine how the Repository function is invoked.
3. **Severity assessment**: Weight your findings by impact:
- **Critical**: Full table scan on a large table in a hot path, or N+1 query pattern
- **Warning**: Full table scan on a medium table, or missing index on a frequently-filtered column
- **Info**: Scan on a small table or infrequent query — note it but don't flag as a problem
4. **Do NOT flag**:
- Queries that already use appropriate indexes
- Scans on tiny tables (< ~100 rows) that are accessed infrequently
- Pre-existing queries not changed in this diff
Return a list of findings, each with: file path, the query (or a description of it), EXPLAIN QUERY PLAN output, table size assessment (small/medium/large), call frequency assessment (hot/warm/cold), severity (critical/warning/info), and a concrete suggestion if action is needed (e.g., "add index on X(Y)" or "batch these N queries into one with WHERE IN").
```
## Step 4: Summarize
After all agents complete, launch a single summarizer agent that receives ALL agent outputs.
```
You are the final reviewer synthesizing code review feedback from 6 specialized agents.
You are the final reviewer synthesizing code review feedback from up to 7 specialized agents.
Here are their findings:
@@ -201,7 +246,7 @@ Your job:
- False positives or theoretical issues unlikely to occur
- Suggestions that would make the code worse or more complex
- Pre-existing issues not introduced by the diff
3. **Prioritize** using this order: Security > Bugs > Spec Violations > Abstraction Issues > CLAUDE.md Violations > Modernization Suggestions
3. **Prioritize** using this order: Security > Bugs > DB Query Performance (critical/warning only) > Spec Violations > Abstraction Issues > CLAUDE.md Violations > Modernization Suggestions > DB Query Performance (info)
4. **Format** the output as a single cohesive review
Output format:
@@ -225,8 +270,9 @@ At the end, if there were modernization suggestions that survived filtering, gro
## Important notes
- Use `subagent_type: "Explore"` for Agent 5 (Abstraction Police) since it needs to search the codebase broadly
- Use `subagent_type: "general-purpose"` for Agent 7 (DB Query Performance) since it needs to run sqlite3 commands via Bash
- Use `subagent_type: "general-purpose"` for the other agents
- Use `model: "sonnet"` for agents 1-6 and `model: "opus"` for the summarizer
- Use `model: "sonnet"` for agents 1-7 and `model: "opus"` for the summarizer
- Pass the actual diff content and file list to each agent — do not tell them to run git commands themselves
- If the diff is very large (>2000 lines), mention this to the user and note that the review may miss some issues
- Present the summarizer's output directly to the user as the final review

View File

@@ -240,6 +240,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
() => friendships(variation),
liveStreams,
splatoonRotations,
variation === "FINALIZED_BRACKET" ? finalizedBracket : undefined,
];
export async function seed(variation?: SeedVariation | null) {
@@ -256,6 +257,260 @@ export async function seed(variation?: SeedVariation | null) {
clearAllTournamentDataCache();
}
const FINALIZED_TOURNAMENT_ID = 7;
const FINALIZED_EVENT_ID = 207;
const FINALIZED_TEAM_ID_OFFSET = 600;
function finalizedBracket() {
// Tournament
sql
.prepare(
`insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized")
values ($id, $mapPickingStyle, $settings, 1)`,
)
.run({
id: FINALIZED_TOURNAMENT_ID,
mapPickingStyle: "AUTO_ALL",
settings: JSON.stringify({
bracketProgression: [
{
type: "single_elimination",
name: "Bracket",
requiresCheckIn: false,
settings: { thirdPlaceMatch: false },
},
],
}),
});
// CalendarEvent
sql
.prepare(
`insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId")
values ($id, $name, $description, $discordInviteCode, $bracketUrl, $authorId, $tournamentId)`,
)
.run({
id: FINALIZED_EVENT_ID,
name: "In The Zone 1",
description: "Finalized tournament for testing",
discordInviteCode: "test",
bracketUrl: "https://example.com",
authorId: ADMIN_ID,
tournamentId: FINALIZED_TOURNAMENT_ID,
});
// CalendarEventDate — recent start time (within 7-day spoiler window)
sql
.prepare(
`insert into "CalendarEventDate" ("eventId", "startTime")
values ($eventId, $startTime)`,
)
.run({
eventId: FINALIZED_EVENT_ID,
startTime: dateToDatabaseTimestamp(
new Date(Date.now() - 1000 * 60 * 60 * 2),
),
});
// 8 teams with 4 members each
const userIds = userIdsInAscendingOrderById();
const teamNames = [
"Alpha",
"Bravo",
"Charlie",
"Delta",
"Echo",
"Foxtrot",
"Golf",
"Hotel",
];
for (let i = 0; i < 8; i++) {
const teamId = FINALIZED_TEAM_ID_OFFSET + i + 1;
sql
.prepare(
`insert into "TournamentTeam" ("id", "name", "createdAt", "tournamentId", "inviteCode", "seed")
values ($id, $name, $createdAt, $tournamentId, $inviteCode, $seed)`,
)
.run({
id: teamId,
name: teamNames[i],
createdAt: dateToDatabaseTimestamp(new Date()),
tournamentId: FINALIZED_TOURNAMENT_ID,
inviteCode: shortNanoid(),
seed: i + 1,
});
sql
.prepare(
`insert into "TournamentTeamCheckIn" ("tournamentTeamId", "checkedInAt")
values ($tournamentTeamId, $checkedInAt)`,
)
.run({
tournamentTeamId: teamId,
checkedInAt: dateToDatabaseTimestamp(new Date()),
});
for (let j = 0; j < 4; j++) {
sql
.prepare(
`insert into "TournamentTeamMember" ("tournamentTeamId", "userId", "isOwner", "createdAt", "role")
values ($tournamentTeamId, $userId, $isOwner, $createdAt, $role)`,
)
.run({
tournamentTeamId: teamId,
userId: userIds.shift()!,
isOwner: j === 0 ? 1 : 0,
createdAt: dateToDatabaseTimestamp(new Date()),
role: j === 0 ? "OWNER" : "REGULAR",
});
}
}
// Bracket structure
const stageId = (
sql
.prepare(
`insert into "TournamentStage" ("tournamentId", "name", "number", "type", "settings")
values ($tournamentId, $name, $number, $type, $settings) returning id`,
)
.get({
tournamentId: FINALIZED_TOURNAMENT_ID,
name: "Bracket",
number: 1,
type: "single_elimination",
settings: JSON.stringify({ thirdPlaceMatch: false }),
}) as { id: number }
).id;
const groupId = (
sql
.prepare(
`insert into "TournamentGroup" ("stageId", "number")
values ($stageId, $number) returning id`,
)
.get({ stageId, number: 1 }) as { id: number }
).id;
const roundMaps = JSON.stringify({ count: 3, type: "BEST_OF" });
const roundIds: number[] = [];
for (let r = 1; r <= 3; r++) {
const roundId = (
sql
.prepare(
`insert into "TournamentRound" ("stageId", "groupId", "number", "maps")
values ($stageId, $groupId, $number, $maps) returning id`,
)
.get({ stageId, groupId, number: r, maps: roundMaps }) as { id: number }
).id;
roundIds.push(roundId);
}
const t = (seed: number) => FINALIZED_TEAM_ID_OFFSET + seed;
// SE 8-team bracket: standard seeding
// QF: 1v8, 4v5, 2v7, 3v6
// SF: winner(1v8) vs winner(4v5), winner(2v7) vs winner(3v6)
// F: winner of SF1 vs winner of SF2
const matches = [
// QF (round 1)
{ round: 0, number: 1, team1: t(1), team2: t(8), winner: t(1) },
{ round: 0, number: 2, team1: t(4), team2: t(5), winner: t(4) },
{ round: 0, number: 3, team1: t(2), team2: t(7), winner: t(2) },
{ round: 0, number: 4, team1: t(3), team2: t(6), winner: t(3) },
// SF (round 2)
{ round: 1, number: 1, team1: t(1), team2: t(4), winner: t(1) },
{ round: 1, number: 2, team1: t(2), team2: t(3), winner: t(2) },
// Finals (round 3)
{ round: 2, number: 1, team1: t(1), team2: t(2), winner: t(1) },
];
const matchInsertStm = sql.prepare(
`insert into "TournamentMatch" ("stageId", "groupId", "roundId", "number", "status", "opponentOne", "opponentTwo")
values ($stageId, $groupId, $roundId, $number, $status, $opponentOne, $opponentTwo) returning id`,
);
const gameResultInsertStm = sql.prepare(
`insert into "TournamentMatchGameResult" ("matchId", "mode", "number", "reporterId", "source", "stageId", "winnerTeamId")
values ($matchId, $mode, $number, $reporterId, $source, $stageId, $winnerTeamId)`,
);
for (const m of matches) {
const matchId = (
matchInsertStm.get({
stageId,
groupId,
roundId: roundIds[m.round],
number: m.number,
status: 4,
opponentOne: JSON.stringify({
id: m.team1,
score: m.winner === m.team1 ? 2 : 0,
result: m.winner === m.team1 ? "win" : "loss",
}),
opponentTwo: JSON.stringify({
id: m.team2,
score: m.winner === m.team2 ? 2 : 0,
result: m.winner === m.team2 ? "win" : "loss",
}),
}) as { id: number }
).id;
// 2 game results (2-0 sweep)
for (let g = 1; g <= 2; g++) {
gameResultInsertStm.run({
matchId,
mode: "SZ",
number: g,
reporterId: ADMIN_ID,
source: "DEFAULT",
stageId: 1,
winnerTeamId: m.winner,
});
}
}
// TournamentResult — placements for all 8 teams
const placements = [
{ teamSeed: 1, placement: 1, setResults: ["W", "W", "W"] },
{ teamSeed: 2, placement: 2, setResults: ["W", "W", "L"] },
{ teamSeed: 3, placement: 3, setResults: ["W", "L"] },
{ teamSeed: 4, placement: 3, setResults: ["W", "L"] },
{ teamSeed: 5, placement: 5, setResults: ["L"] },
{ teamSeed: 6, placement: 5, setResults: ["L"] },
{ teamSeed: 7, placement: 5, setResults: ["L"] },
{ teamSeed: 8, placement: 5, setResults: ["L"] },
];
const resultInsertStm = sql.prepare(
`insert into "TournamentResult" ("tournamentId", "tournamentTeamId", "userId", "placement", "participantCount", "setResults")
values ($tournamentId, $tournamentTeamId, $userId, $placement, $participantCount, $setResults)`,
);
// Insert one result row per team member
for (const p of placements) {
const teamId = t(p.teamSeed);
const members = sql
.prepare(
`select "userId" from "TournamentTeamMember" where "tournamentTeamId" = ?`,
)
.all(teamId) as Array<{ userId: number }>;
for (const member of members) {
resultInsertStm.run({
tournamentId: FINALIZED_TOURNAMENT_ID,
tournamentTeamId: teamId,
userId: member.userId,
placement: p.placement,
participantCount: 8,
setResults: JSON.stringify(p.setResults),
});
}
}
}
function wipeDB() {
const tablesToDelete = [
"ScrimPost",

View File

@@ -522,6 +522,11 @@ export interface CastedMatchesInfo {
lockedMatches: Array<{ twitchAccount: string; matchId: number }>;
/** What matches are streamed currently & where */
castedMatches: { twitchAccount: string; matchId: number }[];
castedMatchHistory?: Array<{
twitchAccount: string;
matchId: number;
timestamp: number;
}>;
}
export interface Tournament {
@@ -971,6 +976,8 @@ export interface UserPreferences {
clockFormat?: "24h" | "12h" | "auto";
/** Is the new widget based user page enabled? (Supporter early preview) */
newProfileEnabled?: boolean;
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
spoilerFreeMode?: boolean;
}
export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const;
@@ -1101,6 +1108,17 @@ export interface TournamentStreamer {
twitchAccount: string;
}
export interface TournamentMatchVod {
id: GeneratedAlways<number>;
matchId: number;
userId: number | null;
platform: string;
account: string;
platformVideoId: string;
timestampSeconds: number;
viewCount: number;
}
export interface BanLog {
id: GeneratedAlways<number>;
userId: number;
@@ -1352,6 +1370,7 @@ export interface DB {
TournamentBracketProgressionOverride: TournamentBracketProgressionOverride;
TournamentOrganizationBannedUser: TournamentOrganizationBannedUser;
TournamentStreamer: TournamentStreamer;
TournamentMatchVod: TournamentMatchVod;
TrustRelationship: TrustRelationship;
Friendship: Friendship;
FriendRequest: FriendRequest;

View File

@@ -6,4 +6,5 @@ export const SEED_VARIATIONS = [
"NZAP_IN_TEAM",
"NO_SCRIMS",
"NO_SQ_GROUPS",
"FINALIZED_BRACKET",
] as const;

View File

@@ -52,6 +52,7 @@ export interface ShowcaseCalendarEvent extends CommonEvent {
notShownMembersCount: number;
div: string | null;
} | null;
hasVods?: boolean;
}
export interface GroupedCalendarEvents {

View File

@@ -148,6 +148,7 @@
}
.firstPlacers {
position: relative;
margin-inline: auto;
margin-top: var(--s-5);
margin-block-end: var(--s-4);
@@ -170,6 +171,24 @@
min-height: initial;
}
.vodIndicator {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
background-color: var(--color-bg-higher);
border-radius: var(--radius-selector);
height: var(--selector-size);
width: max-content;
padding: 0 var(--s-1-5);
display: flex;
align-items: center;
gap: var(--s-1);
white-space: nowrap;
&:not(:first-child) {
margin-inline-start: var(--s-1);
}
}
.badgePill {
background-color: var(--color-bg-higher);
border-radius: var(--radius-selector);

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { Trophy, Users } from "lucide-react";
import { ShieldMinus, Trophy, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
@@ -9,6 +9,7 @@ import { Image, ModeImage } from "~/components/Image";
import { TierPill } from "~/components/TierPill";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { useHydrated } from "~/hooks/useHydrated";
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl } from "~/utils/urls";
@@ -27,6 +28,7 @@ export function TournamentCard({
}) {
const isHydrated = useHydrated();
const { formatDateTimeSmartMinutes, formatDistanceToNow } = useTimeFormat();
const { isCensored, reveal } = useSpoilerFree();
const isShowcase = tournament.type === "showcase";
const isCalendar = tournament.type === "calendar";
@@ -116,10 +118,19 @@ export function TournamentCard({
</div>
) : null}
{isShowcase && tournament.firstPlacer ? (
<TournamentFirstPlacers firstPlacer={tournament.firstPlacer} />
<TournamentFirstPlacers
firstPlacer={tournament.firstPlacer}
censored={isCensored(tournament.id)}
/>
) : null}
</Link>
<div className="stack horizontal justify-between items-center">
{isShowcase && tournament.firstPlacer && isCensored(tournament.id) ? (
<SpoilerRevealPill onReveal={() => reveal(tournament.id)} />
) : null}
{isShowcase && "hasVods" in tournament && tournament.hasVods ? (
<div className={styles.vodIndicator}>📺 VODs</div>
) : null}
{tournament.modes ? <ModesPill modes={tournament.modes} /> : null}
<div
className={clsx(styles.pillsContainer, {
@@ -147,15 +158,17 @@ export function TournamentCard({
function TournamentFirstPlacers({
firstPlacer,
censored,
}: {
firstPlacer: NonNullable<ShowcaseCalendarEvent["firstPlacer"]>;
censored: boolean;
}) {
const { t } = useTranslation(["front"]);
return (
<div className={styles.firstPlacers}>
<div className="stack xs horizontal items-center text-xs">
{firstPlacer.logoUrl ? (
{!censored && firstPlacer.logoUrl ? (
<img
src={firstPlacer.logoUrl}
alt=""
@@ -165,7 +178,7 @@ function TournamentFirstPlacers({
) : null}{" "}
<div className="stack items-start">
<span className={styles.firstPlacersTeamName}>
{firstPlacer.teamName}
{censored ? "???" : firstPlacer.teamName}
</span>
<div className="text-xxxs text-lighter font-bold text-uppercase">
{t("front:showcase.card.winner")}
@@ -176,11 +189,13 @@ function TournamentFirstPlacers({
<div className="text-xxs stack items-start mt-1">
{firstPlacer.members.map((member) => (
<div key={member.id} className="stack horizontal xs items-center">
{member.country ? <Flag tiny countryCode={member.country} /> : null}
{member.username}{" "}
{!censored && member.country ? (
<Flag tiny countryCode={member.country} />
) : null}
{censored ? "???" : member.username}{" "}
</div>
))}
{firstPlacer.notShownMembersCount > 0 ? (
{!censored && firstPlacer.notShownMembersCount > 0 ? (
<div className="font-bold text-lighter">
+{firstPlacer.notShownMembersCount}
</div>
@@ -190,6 +205,21 @@ function TournamentFirstPlacers({
);
}
function SpoilerRevealPill({ onReveal }: { onReveal: () => void }) {
const { t } = useTranslation(["common"]);
return (
<SendouButton
variant="outlined"
size="miniscule"
onPress={onReveal}
icon={<ShieldMinus />}
>
{t("common:actions.reveal")}
</SendouButton>
);
}
function ModesPill({ modes }: { modes: NonNullable<CalendarEvent["modes"]> }) {
const size = 16;

View File

@@ -314,6 +314,7 @@ function mapTournamentFromDB(
hidden: Boolean(tournament.hidden),
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
modes: null,
hasVods: (tournament.vodCount ?? 0) > 0,
firstPlacer:
highestDivWinners.length > 0
? {

View File

@@ -40,6 +40,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
break;
}
case "UPDATE_SPOILER_FREE_MODE": {
await UserRepository.updatePreferences(user.id, {
spoilerFreeMode: data.newValue,
});
break;
}
case "UPDATE_NO_SCREEN": {
await QSettingsRepository.updateNoScreen({
userId: user.id,

View File

@@ -30,6 +30,7 @@ import {
clockFormatSchema,
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
updateNoScreenSchema,
} from "../settings-schemas";
import styles from "./settings.module.css";
@@ -114,6 +115,16 @@ export default function SettingsPage() {
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={spoilerFreeModeSchema}
defaultValues={{
newValue: user.preferences.spoilerFreeMode ?? false,
}}
autoSubmit
revalidateRoot
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={updateNoScreenSchema}
defaultValues={{

View File

@@ -35,6 +35,14 @@ export const disallowScrimPickupsFromUntrustedSchema = z.object({
}),
});
export const spoilerFreeModeSchema = z.object({
_action: stringConstant("UPDATE_SPOILER_FREE_MODE"),
newValue: toggle({
label: "labels.spoilerFreeMode",
bottomText: "bottomTexts.spoilerFreeMode",
}),
});
export const updateNoScreenSchema = z.object({
_action: stringConstant("UPDATE_NO_SCREEN"),
newValue: toggle({
@@ -47,6 +55,7 @@ export const settingsEditSchema = z.union([
customThemeSchema,
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
updateNoScreenSchema,
clockFormatSchema,
]);

View File

@@ -0,0 +1,188 @@
import { subDays } from "date-fns";
import type { Insertable } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { TOURNAMENT } from "../tournament/tournament-constants";
export type VodsByTournamentId = Awaited<
ReturnType<typeof findVodsByTournamentId>
>;
export function findVodsByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentMatchVod")
.innerJoin(
"TournamentMatch",
"TournamentMatch.id",
"TournamentMatchVod.matchId",
)
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select([
"TournamentMatchVod.matchId",
"TournamentMatchVod.userId",
"TournamentMatchVod.platform",
"TournamentMatchVod.account",
"TournamentMatchVod.platformVideoId",
"TournamentMatchVod.timestampSeconds",
"TournamentMatchVod.viewCount",
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.orderBy("TournamentMatchVod.viewCount", "desc")
.execute();
}
export function insertMany(vods: Insertable<DB["TournamentMatchVod"]>[]) {
if (vods.length === 0) return;
return db
.insertInto("TournamentMatchVod")
.values(vods)
.onConflict((oc) =>
oc.columns(["matchId", "account"]).doUpdateSet((eb) => ({
viewCount: eb.ref("excluded.viewCount"),
timestampSeconds: eb.ref("excluded.timestampSeconds"),
platformVideoId: eb.ref("excluded.platformVideoId"),
})),
)
.execute();
}
export function findFinalizedTournamentsNeedingVods() {
const oneDayAgo = dateToDatabaseTimestamp(subDays(new Date(), 1));
return db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select(["Tournament.id"])
.where("Tournament.isFinalized", "=", 1)
.where("CalendarEventDate.startTime", ">", oneDayAgo)
.where(({ eb, selectFrom }) =>
eb(
selectFrom("TournamentMatchVod")
.innerJoin(
"TournamentMatch",
"TournamentMatch.id",
"TournamentMatchVod.matchId",
)
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select(({ fn }) => [fn.countAll<number>().as("count")])
.whereRef("TournamentStage.tournamentId", "=", "Tournament.id"),
"=",
0,
),
)
.execute();
}
export function deleteObsolete() {
const cutoff = dateToDatabaseTimestamp(
subDays(new Date(), TOURNAMENT.VOD_VISIBILITY_DAYS),
);
return db
.deleteFrom("TournamentMatchVod")
.where(
"matchId",
"in",
db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentStage.tournamentId",
)
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select("TournamentMatch.id")
.where("CalendarEventDate.startTime", "<", cutoff),
)
.executeTakeFirst();
}
export function findStreamersByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentStreamer")
.select(["TournamentStreamer.twitchAccount", "TournamentStreamer.userId"])
.where("TournamentStreamer.tournamentId", "=", tournamentId)
.execute();
}
export function findMatchesWithStartedAt(tournamentId: number) {
return db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin(
"TournamentRound",
"TournamentRound.id",
"TournamentMatch.roundId",
)
.innerJoin(
"TournamentGroup",
"TournamentGroup.id",
"TournamentMatch.groupId",
)
.select((eb) => [
"TournamentMatch.id",
"TournamentMatch.startedAt",
"TournamentStage.type as stageType",
"TournamentRound.number as roundNumber",
"TournamentGroup.number as groupNumber",
jsonArrayFrom(
eb
.selectFrom("TournamentMatchGameResultParticipant")
.innerJoin(
"TournamentMatchGameResult",
"TournamentMatchGameResult.id",
"TournamentMatchGameResultParticipant.matchGameResultId",
)
.select(["TournamentMatchGameResultParticipant.userId"])
.whereRef(
"TournamentMatchGameResult.matchId",
"=",
"TournamentMatch.id",
)
.groupBy("TournamentMatchGameResultParticipant.userId"),
).as("participants"),
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.where("TournamentMatch.startedAt", "is not", null)
.execute();
}
export async function findCastedMatchHistoryByTournamentId(
tournamentId: number,
) {
const result = await db
.selectFrom("Tournament")
.select("Tournament.castedMatchesInfo")
.where("Tournament.id", "=", tournamentId)
.executeTakeFirst();
return result?.castedMatchesInfo?.castedMatchHistory ?? [];
}

View File

@@ -100,6 +100,7 @@ vi.mock("~/features/auth/core/user", () => ({
vi.mock("~/features/tournament/routes/to.$id", () => ({
useTournament: () => mockTournament,
useTournamentVods: () => [],
useBracketExpanded: () => ({
bracketExpanded: true,
setBracketExpanded: vi.fn(),

View File

@@ -5,6 +5,7 @@ import { getRounds } from "../../core/rounds";
import styles from "./bracket.module.css";
import { Match } from "./Match";
import { RoundHeader } from "./RoundHeader";
import { useBracketSpoilerCensor } from "./useBracketSpoilerCensor";
interface EliminationBracketSideProps {
bracket: BracketType;
@@ -18,11 +19,15 @@ const GAP = 32;
const MATCH_SPACING = MATCH_HEIGHT + GAP;
export function EliminationBracketSide(props: EliminationBracketSideProps) {
const { censored, matchCensorLevel } = useBracketSpoilerCensor();
const rounds = getRounds({ ...props, bracketData: props.bracket.data });
const hiddenRoundIds = new Set(
rounds
.filter((round, roundIdx) => {
if (censored && round.name === TOURNAMENT.ROUND_NAMES.BRACKET_RESET) {
return true;
}
if (props.isExpanded) return false;
if (roundIdx >= rounds.length - 2) return false;
@@ -110,6 +115,8 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
nextRound?.name === TOURNAMENT.ROUND_NAMES.THIRD_PLACE_MATCH
)
return "none" as const;
if (nextRound && hiddenRoundIds.has(nextRound.id))
return "none" as const;
if (nextRoundMatchCount === matches.length)
return "straight" as const;
return matchIdx % 2 === 0
@@ -146,6 +153,22 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
? "losers"
: undefined
}
spoilerCensor={matchCensorLevel({
bracketType:
props.type === "single"
? "single_elimination"
: "double_elimination",
roundName: round.name,
roundNumber: round.number,
roundIdx,
matchType:
round.name === TOURNAMENT.ROUND_NAMES.GRAND_FINALS ||
round.name === TOURNAMENT.ROUND_NAMES.BRACKET_RESET
? "grands"
: props.type === "losers"
? "losers"
: "winners",
})}
lineType={lineType}
lineVerticalExtend={verticalExtend}
/>

View File

@@ -7,10 +7,17 @@ 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 { useTournament } from "~/features/tournament/routes/to.$id";
import {
useTournament,
useTournamentVods,
} from "~/features/tournament/routes/to.$id";
import { databaseTimestampToDate } from "~/utils/dates";
import type { Unpacked } from "~/utils/types";
import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls";
import {
tournamentMatchPage,
tournamentStreamsPage,
vodUrl,
} from "~/utils/urls";
import type { Bracket } from "../../core/Bracket";
import * as Deadline from "../../core/Deadline";
import type { TournamentData } from "../../core/Tournament.server";
@@ -31,6 +38,7 @@ interface MatchProps {
hideMatchTimer?: boolean;
lineType?: LineType;
lineVerticalExtend?: number;
spoilerCensor?: "full" | "score-only";
}
export function Match(props: MatchProps) {
@@ -68,6 +76,7 @@ export function Match(props: MatchProps) {
function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
const tournament = useTournament();
const vods = useTournamentVods();
const streamingParticipants = tournament.streamingParticipantIds ?? [];
const prefix = () => {
@@ -80,6 +89,7 @@ function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
const isOver =
match.opponent1?.result === "win" || match.opponent2?.result === "win";
const matchVods = isOver ? vods.filter((v) => v.matchId === match.id) : [];
const hasStreams = () => {
if (isOver || !match.opponent1?.id || !match.opponent2?.id) return false;
if (
@@ -141,6 +151,23 @@ function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
>
<MatchStreams match={match} />
</SendouPopover>
) : matchVods.length > 0 ? (
<SendouPopover
placement="top"
popoverClassName="w-max"
trigger={
<SendouButton
className={clsx(
styles.matchHeaderBox,
styles.matchHeaderBoxButton,
)}
>
📺 VOD
</SendouButton>
}
>
<MatchVods vods={matchVods} />
</SendouPopover>
) : null}
</div>
);
@@ -177,6 +204,7 @@ function MatchRow({
isPreview,
showSimulation,
bracket,
spoilerCensor,
}: MatchProps & { side: 1 | 2 }) {
const user = useUser();
const tournament = useTournament();
@@ -185,6 +213,7 @@ function MatchRow({
const opponent = match[`opponent${side}`];
const score = () => {
if (spoilerCensor) return null;
if (!match.opponent1?.id || !match.opponent2?.id || isPreview) return null;
const opponentScore = opponent!.score;
@@ -208,7 +237,7 @@ function MatchRow({
return opponentScore ?? 0;
};
const isLoser = opponent?.result === "loss";
const isLoser = spoilerCensor ? false : opponent?.result === "loss";
const { team, simulated } = (() => {
if (opponent?.id) {
@@ -228,15 +257,24 @@ function MatchRow({
const ownTeam = tournament.teamMemberOfByUser(user);
const logoSrc = team ? tournament.tournamentTeamLogoSrc(team) : null;
const showAvatar = !simulated && team;
const showAvatar = spoilerCensor === "full" ? false : !simulated && team;
const isBigSeedNumber = team?.seed && team.seed > 99;
const isBigSeedNumber =
spoilerCensor === "full" ? false : team?.seed && team.seed > 99;
const displayedSeed = spoilerCensor === "full" ? null : team?.seed;
const displayedName =
spoilerCensor === "full" ? "???" : (team?.name ?? "???");
return (
<div
className={clsx("stack horizontal", { "text-lighter": isLoser })}
data-participant-id={team?.id}
title={team?.members.map((m) => m.username).join(", ")}
title={
spoilerCensor === "full"
? undefined
: team?.members.map((m) => m.username).join(", ")
}
>
<div
className={clsx(styles.matchSeed, {
@@ -244,13 +282,13 @@ function MatchRow({
[styles.matchSeedWide]: isBigSeedNumber,
})}
>
{team?.seed}
{displayedSeed}
</div>
{showAvatar ? (
<Avatar
size="xxxs"
url={logoSrc}
identiconInput={team.name}
identiconInput={team!.name}
className="mr-1"
/>
) : null}
@@ -267,7 +305,7 @@ function MatchRow({
invisible: !team,
})}
>
{team?.name ?? "???"}
{displayedName}
</div>{" "}
<div className={styles.matchScore}>{score()}</div>
</div>
@@ -321,6 +359,58 @@ function MatchStreams({ match }: Pick<MatchProps, "match">) {
);
}
interface MatchVodsProps {
vods: Array<{
matchId: number;
userId: number | null;
platform: string;
account: string;
platformVideoId: string;
timestampSeconds: number;
viewCount: number;
}>;
}
function MatchVods({ vods }: MatchVodsProps) {
const tournament = useTournament();
return (
<div className={clsx("stack md", parentStyles.streamPopover)}>
{vods.map((vod) => {
const team = vod.userId
? tournament.ctx.teams.find((t) =>
t.members.some((m) => m.userId === vod.userId),
)
: null;
const user = team?.members.find((m) => m.userId === vod.userId);
return (
<a
key={`${vod.platformVideoId}-${vod.account}`}
href={vodUrl(vod)}
target="_blank"
rel="noopener noreferrer"
className={parentStyles.vodLink}
>
{user ? (
<>
<Avatar size="xxs" user={user} />
<span className="font-semi-bold">{user.username}</span>
<span className="text-theme-secondary">{team?.name}</span>
</>
) : (
<span className="font-semi-bold">{vod.account}</span>
)}
<span className="text-lighter text-xs">
{vod.viewCount.toLocaleString()} views
</span>
</a>
);
})}
</div>
);
}
function MatchTimer({ match, bracket }: Pick<MatchProps, "match" | "bracket">) {
const [now, setNow] = React.useState(new Date());
const tournament = useTournament();

View File

@@ -5,8 +5,10 @@ import styles from "./bracket.module.css";
import { Match } from "./Match";
import { PlacementsTable } from "./PlacementsTable";
import { RoundHeader } from "./RoundHeader";
import { useBracketSpoilerCensor } from "./useBracketSpoilerCensor";
export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
const { censored, matchCensorLevel } = useBracketSpoilerCensor();
const groups = getGroups(bracket);
return (
@@ -75,6 +77,12 @@ export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
bracket={bracket}
type="groups"
group={groupName.split(" ")[1]}
spoilerCensor={matchCensorLevel({
bracketType: "round_robin",
roundNumber: round.number,
roundIdx: 0,
matchType: "groups",
})}
/>
);
})}
@@ -83,11 +91,13 @@ export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
);
})}
</div>
<PlacementsTable
bracket={bracket}
groupId={groupId}
allMatchesFinished={allMatchesFinished}
/>
{censored ? null : (
<PlacementsTable
bracket={bracket}
groupId={groupId}
allMatchesFinished={allMatchesFinished}
/>
)}
</div>
);
})}

View File

@@ -16,6 +16,7 @@ import { groupNumberToLetters } from "../../tournament-bracket-utils";
import { Match } from "./Match";
import { PlacementsTable } from "./PlacementsTable";
import { RoundHeader } from "./RoundHeader";
import { useBracketSpoilerCensor } from "./useBracketSpoilerCensor";
export function SwissBracket({
bracket,
@@ -27,6 +28,7 @@ export function SwissBracket({
const user = useUser();
const tournament = useTournament();
const { bracketExpanded } = useBracketExpanded();
const { censored, matchCensorLevel } = useBracketSpoilerCensor();
const groups = getGroups(bracket);
const [selectedGroupId, setSelectedGroupId] = useSearchParamState({
@@ -241,11 +243,17 @@ export function SwissBracket({
type="groups"
group={selectedGroup.groupName.split(" ")[1]}
hideMatchTimer
spoilerCensor={matchCensorLevel({
bracketType: "swiss",
roundNumber: round.number,
roundIdx: roundI,
matchType: "groups",
})}
/>
);
})}
</div>
{teamWithBye ? (
{teamWithBye && !(censored && round.number > 1) ? (
<div
className="text-xs text-lighter font-semi-bold"
data-testid="bye-team"
@@ -257,11 +265,13 @@ export function SwissBracket({
);
})}
</div>
<PlacementsTable
bracket={bracket}
groupId={selectedGroupId}
allMatchesFinished={allRoundsFinished()}
/>
{censored ? null : (
<PlacementsTable
bracket={bracket}
groupId={selectedGroupId}
allMatchesFinished={allRoundsFinished()}
/>
)}
</div>
</div>
);

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { matchCensorLevel } from "./useBracketSpoilerCensor";
const BASE_ARGS = {
censored: true,
roundNumber: 1,
roundIdx: 0,
} as const;
describe("matchCensorLevel()", () => {
it("returns undefined when not censored", () => {
expect(
matchCensorLevel({
...BASE_ARGS,
censored: false,
bracketType: "double_elimination",
}),
).toBeUndefined();
});
it("returns 'score-only' for DE winners round 1", () => {
expect(
matchCensorLevel({
...BASE_ARGS,
bracketType: "double_elimination",
matchType: "winners",
}),
).toBe("score-only");
});
it("returns 'full' for DE winners round 2+", () => {
expect(
matchCensorLevel({
...BASE_ARGS,
bracketType: "double_elimination",
matchType: "winners",
roundIdx: 1,
roundNumber: 2,
}),
).toBe("full");
});
it("returns 'full' for DE losers round", () => {
expect(
matchCensorLevel({
...BASE_ARGS,
bracketType: "double_elimination",
matchType: "losers",
}),
).toBe("full");
});
it("returns 'score-only' for swiss round 1", () => {
expect(
matchCensorLevel({
...BASE_ARGS,
bracketType: "swiss",
}),
).toBe("score-only");
});
it("returns 'full' for swiss round 2+", () => {
expect(
matchCensorLevel({
...BASE_ARGS,
bracketType: "swiss",
roundNumber: 2,
roundIdx: 1,
}),
).toBe("full");
});
it("returns 'score-only' for round robin", () => {
expect(
matchCensorLevel({
...BASE_ARGS,
bracketType: "round_robin",
}),
).toBe("score-only");
});
});

View File

@@ -0,0 +1,72 @@
import { differenceInDays } from "date-fns";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
export type SpoilerCensor = "full" | "score-only" | undefined;
export function useBracketSpoilerCensor() {
const tournament = useTournament();
const { isEnabled, isCensored, reveal, hide } = useSpoilerFree();
const withinSpoilerWindow =
differenceInDays(new Date(), tournament.ctx.startTime) <
TOURNAMENT.VOD_VISIBILITY_DAYS;
const censored = withinSpoilerWindow && isCensored(tournament.ctx.id);
const canToggle = isEnabled && withinSpoilerWindow;
return {
censored,
canToggle,
matchCensorLevel: (
args: Omit<MatchCensorLevelArgs, "censored">,
): SpoilerCensor =>
matchCensorLevel({ ...args, censored: Boolean(censored) }),
reveal: () => reveal(tournament.ctx.id),
hide: () => hide(tournament.ctx.id),
};
}
interface MatchCensorLevelArgs {
censored: boolean;
bracketType:
| "double_elimination"
| "single_elimination"
| "swiss"
| "round_robin";
roundName?: string;
roundNumber: number;
roundIdx: number;
matchType?: "winners" | "losers" | "grands" | "groups";
}
export function matchCensorLevel(args: MatchCensorLevelArgs): SpoilerCensor {
if (!args.censored) return undefined;
if (args.roundName === TOURNAMENT.ROUND_NAMES.BRACKET_RESET) {
return "full";
}
if (
args.bracketType === "double_elimination" ||
args.bracketType === "single_elimination"
) {
if (args.matchType === "winners" && args.roundIdx === 0) {
return "score-only";
}
return "full";
}
if (args.bracketType === "swiss") {
if (args.roundNumber === 1) return "score-only";
return "full";
}
if (args.bracketType === "round_robin") {
return "score-only";
}
return "full";
}

View File

@@ -1,5 +1,13 @@
import { sub } from "date-fns";
import { Check, Eye, EyeOff, Map as MapIcon, Stamp } from "lucide-react";
import {
Check,
Eye,
EyeOff,
Map as MapIcon,
ShieldMinus,
ShieldPlus,
Stamp,
} from "lucide-react";
import * as React from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useTranslation } from "react-i18next";
@@ -29,6 +37,7 @@ import {
} from "../../tournament/routes/to.$id";
import { action } from "../actions/to.$id.brackets.server";
import { Bracket } from "../components/Bracket";
import { useBracketSpoilerCensor } from "../components/Bracket/useBracketSpoilerCensor";
import { BracketMapListDialog } from "../components/BracketMapListDialog";
import { TournamentTeamActions } from "../components/TournamentTeamActions";
import type { Bracket as BracketType } from "../core/Bracket";
@@ -39,7 +48,7 @@ export { action };
import styles from "../tournament-bracket.module.css";
export default function TournamentBracketsPage() {
const { t } = useTranslation(["tournament"]);
const { t } = useTranslation(["common", "tournament"]);
const { formatDateTime, formatTime } = useTimeFormat();
const visibility = useVisibilityChange();
const { revalidate } = useRevalidator();
@@ -84,6 +93,13 @@ export default function TournamentBracketsPage() {
tournament.autonomousSubs &&
teamProgressStatus?.type !== "THANKS_FOR_PLAYING";
const {
censored,
canToggle,
reveal: revealSpoiler,
hide: hideSpoiler,
} = useBracketSpoilerCensor();
const showPrepareMapsButton =
tournament.isOrganizer(user) &&
!bracket.canBeStarted &&
@@ -221,6 +237,15 @@ export default function TournamentBracketsPage() {
{t("tournament:actions.finalize.button")}
</LinkButton>
) : null}
{censored ? (
<SendouButton onPress={revealSpoiler} icon={<ShieldMinus />}>
{t("common:spoilerFree.showResults")}
</SendouButton>
) : canToggle ? (
<SendouButton onPress={hideSpoiler} icon={<ShieldPlus />}>
{t("common:spoilerFree.hideResults")}
</SendouButton>
) : null}
{showPrepareMapsButton ? (
// Error Boundary because preparing maps is optional, so no need to make the whole page inaccessible if it fails
<ErrorBoundary fallback={null}>

View File

@@ -513,6 +513,17 @@
width: 280px;
}
.vodLink {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-xs);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.actionSectionWrapper {
& [class*="tabPanel"] {
background-color: var(--color-bg-high);

View File

@@ -22,6 +22,7 @@ import {
} from "~/utils/kysely.server";
import type { Unwrapped } from "~/utils/types";
import type { TournamentTierNumber } from "./core/tiering";
import { updatedCastedMatchesInfo } from "./tournament-utils";
export type FindById = NonNullable<Unwrapped<typeof findById>>;
export async function findById(id: number) {
@@ -539,6 +540,21 @@ export function forShowcase() {
).as("pickupAvatarUrl"),
]),
).as("firstPlacers"),
eb
.selectFrom("TournamentMatchVod")
.innerJoin(
"TournamentMatch",
"TournamentMatch.id",
"TournamentMatchVod.matchId",
)
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.whereRef("TournamentStage.tournamentId", "=", "Tournament.id")
.select(({ fn }) => [fn.countAll<number>().as("count")])
.as("vodCount"),
])
.where("CalendarEventDate.startTime", ">", databaseTimestampWeekAgo())
.orderBy("CalendarEventDate.startTime", "asc")
@@ -1016,34 +1032,11 @@ export function setMatchAsCasted({
tournamentId,
);
let newCastedMatchesInfo: CastedMatchesInfo;
if (twitchAccount === null) {
newCastedMatchesInfo = {
...castedMatchesInfo,
castedMatches: castedMatchesInfo.castedMatches.filter(
(cm) => cm.matchId !== matchId,
),
lockedMatches: castedMatchesInfo.lockedMatches.filter(
(lm) => lm.matchId !== matchId,
),
};
} else {
newCastedMatchesInfo = {
...castedMatchesInfo,
castedMatches: castedMatchesInfo.castedMatches
.filter(
(cm) =>
// currently a match can only be streamed by one account
// and a cast can only stream one match at a time
// these can change in the future
cm.matchId !== matchId && cm.twitchAccount !== twitchAccount,
)
.concat([{ twitchAccount, matchId }]),
lockedMatches: castedMatchesInfo.lockedMatches.filter(
(lm) => lm.matchId !== matchId,
),
};
}
const newCastedMatchesInfo = updatedCastedMatchesInfo(castedMatchesInfo, {
matchId,
twitchAccount,
timestamp: databaseTimestampNow(),
});
await trx
.updateTable("Tournament")

View File

@@ -2,8 +2,12 @@ import { isAfter, subDays } from "date-fns";
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { LEAGUES } from "~/features/tournament/tournament-constants";
import {
LEAGUES,
TOURNAMENT,
} from "~/features/tournament/tournament-constants";
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentMatchVodRepository from "~/features/tournament-bracket/TournamentMatchVodRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
@@ -19,6 +23,7 @@ export type TournamentLoaderData = {
preparedMaps:
| Awaited<ReturnType<typeof TournamentRepository.findPreparedMapsById>>
| undefined;
vods: TournamentMatchVodRepository.VodsByTournamentId | undefined;
};
export const loader = async ({ params }: LoaderFunctionArgs) => {
@@ -65,6 +70,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
? await TournamentRepository.hasChildTournaments(tournamentId)
: false;
const showVods =
tournament.ctx.isFinalized &&
isAfter(
databaseTimestampToDate(tournament.ctx.startTime),
subDays(new Date(), TOURNAMENT.VOD_VISIBILITY_DAYS),
);
// skip expensive rr7 data serialization (hot path loader)
return JSON.stringify({
tournament,
@@ -76,5 +88,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
isTournamentOrganizer && !tournament.ctx.isFinalized
? await TournamentRepository.findPreparedMapsById(tournamentId)
: undefined,
vods: showVods
? await TournamentMatchVodRepository.findVodsByTournamentId(tournamentId)
: undefined,
});
};

View File

@@ -1,7 +1,11 @@
import clsx from "clsx";
import { differenceInDays } from "date-fns";
import { ShieldMinus } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import {
SendouTab,
SendouTabList,
@@ -13,6 +17,7 @@ import { InfoPopover } from "~/components/InfoPopover";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import type { Standing } from "~/features/tournament-bracket/core/Bracket";
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
import {
SPR_INFO_URL,
tournamentMatchPage,
@@ -20,10 +25,33 @@ import {
} from "~/utils/urls";
import * as Standings from "../core/Standings";
import styles from "../tournament.module.css";
import { TOURNAMENT } from "../tournament-constants";
import { useTournament } from "./to.$id";
export default function TournamentResultsPage() {
const { t } = useTranslation(["common"]);
const tournament = useTournament();
const { isCensored, reveal } = useSpoilerFree();
const withinSpoilerWindow =
differenceInDays(new Date(), tournament.ctx.startTime) <
TOURNAMENT.VOD_VISIBILITY_DAYS;
const censored = withinSpoilerWindow && isCensored(tournament.ctx.id);
if (censored) {
return (
<div className={styles.spoilerRevealContainer}>
<SendouButton
variant="outlined"
size="big"
onPress={() => reveal(tournament.ctx.id)}
icon={<ShieldMinus />}
>
{t("common:spoilerFree.showResults")}
</SendouButton>
</div>
);
}
const standingsResult = Standings.tournamentStandings(tournament);

View File

@@ -213,6 +213,7 @@ export function TournamentLayout() {
hasChildTournaments: data.hasChildTournaments,
friendCodes: data.friendCodes,
preparedMaps: data.preparedMaps,
vods: data.vods ?? [],
} satisfies TournamentContext
}
/>
@@ -229,6 +230,7 @@ type TournamentContext = {
friendCode?: string;
friendCodes?: TournamentLoaderData["friendCodes"];
preparedMaps: TournamentLoaderData["preparedMaps"];
vods: NonNullable<TournamentLoaderData["vods"]>;
};
export function useTournament() {
@@ -254,6 +256,10 @@ export function useTournamentPreparedMaps() {
return useOutletContext<TournamentContext>().preparedMaps;
}
export function useTournamentVods() {
return useOutletContext<TournamentContext>().vods;
}
function useTournamentChatLabels(tournament: Tournament) {
const chatContext = useChatContext();
const setChatLabels = chatContext?.setChatLabels;

View File

@@ -17,6 +17,8 @@ export const TOURNAMENT = {
SWISS_DEFAULT_ROUND_COUNT: 5,
SE_DEFAULT_HAS_THIRD_PLACE_MATCH: true,
MAX_SAVED_COUNT: 20,
/** How many days after a tournament ends VOD links are shown on the bracket */
VOD_VISIBILITY_DAYS: 7,
ROUND_NAMES: {
WB_FINALS: "WB Finals",
GRAND_FINALS: "Grand Finals",

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { CastedMatchesInfo } from "~/db/tables";
import type { ParsedBracket } from "../tournament-bracket/core/Progression";
import {
compareTeamsForOrdering,
@@ -6,6 +7,7 @@ import {
getBracketProgressionLabel,
sortTeamsBySeeding,
type TeamForOrdering,
updatedCastedMatchesInfo,
} from "./tournament-utils";
const createTeam = (
@@ -457,3 +459,168 @@ describe("getBracketProgressionLabel", () => {
expect(result).toBe("C");
});
});
const emptyCastedMatchesInfo = (): CastedMatchesInfo => ({
castedMatches: [],
lockedMatches: [],
castedMatchHistory: [],
});
describe("updatedCastedMatchesInfo", () => {
describe("assigning a cast", () => {
it("adds entry to castedMatches and history", () => {
const result = updatedCastedMatchesInfo(emptyCastedMatchesInfo(), {
matchId: 1,
twitchAccount: "streamer_a",
timestamp: 1000,
});
expect(result.castedMatches).toEqual([
{ twitchAccount: "streamer_a", matchId: 1 },
]);
expect(result.castedMatchHistory).toEqual([
{ twitchAccount: "streamer_a", matchId: 1, timestamp: 1000 },
]);
});
it("removes prior castedMatches entry for same matchId", () => {
const current = emptyCastedMatchesInfo();
current.castedMatches = [{ twitchAccount: "old_streamer", matchId: 1 }];
const result = updatedCastedMatchesInfo(current, {
matchId: 1,
twitchAccount: "new_streamer",
timestamp: 1000,
});
expect(result.castedMatches).toEqual([
{ twitchAccount: "new_streamer", matchId: 1 },
]);
});
it("removes prior castedMatches entry for same twitchAccount", () => {
const current = emptyCastedMatchesInfo();
current.castedMatches = [{ twitchAccount: "streamer_a", matchId: 1 }];
const result = updatedCastedMatchesInfo(current, {
matchId: 2,
twitchAccount: "streamer_a",
timestamp: 1000,
});
expect(result.castedMatches).toEqual([
{ twitchAccount: "streamer_a", matchId: 2 },
]);
});
it("removes matchId from lockedMatches", () => {
const current = emptyCastedMatchesInfo();
current.lockedMatches = [
{ twitchAccount: "streamer_a", matchId: 1 },
{ twitchAccount: "streamer_b", matchId: 2 },
];
const result = updatedCastedMatchesInfo(current, {
matchId: 1,
twitchAccount: "streamer_a",
timestamp: 1000,
});
expect(result.lockedMatches).toEqual([
{ twitchAccount: "streamer_b", matchId: 2 },
]);
});
it("deduplicates history by matchId when channel is corrected", () => {
const current = emptyCastedMatchesInfo();
current.castedMatchHistory = [
{ twitchAccount: "wrong_channel", matchId: 1, timestamp: 500 },
{ twitchAccount: "other_streamer", matchId: 2, timestamp: 600 },
];
const result = updatedCastedMatchesInfo(current, {
matchId: 1,
twitchAccount: "correct_channel",
timestamp: 1000,
});
expect(result.castedMatchHistory).toEqual([
{ twitchAccount: "other_streamer", matchId: 2, timestamp: 600 },
{ twitchAccount: "correct_channel", matchId: 1, timestamp: 1000 },
]);
});
it("deduplicates history when same account+matchId is reassigned", () => {
const current = emptyCastedMatchesInfo();
current.castedMatchHistory = [
{ twitchAccount: "streamer_a", matchId: 1, timestamp: 500 },
];
const result = updatedCastedMatchesInfo(current, {
matchId: 1,
twitchAccount: "streamer_a",
timestamp: 1000,
});
expect(result.castedMatchHistory).toEqual([
{ twitchAccount: "streamer_a", matchId: 1, timestamp: 1000 },
]);
});
it("initializes history when undefined", () => {
const current: CastedMatchesInfo = {
castedMatches: [],
lockedMatches: [],
};
const result = updatedCastedMatchesInfo(current, {
matchId: 1,
twitchAccount: "streamer_a",
timestamp: 1000,
});
expect(result.castedMatchHistory).toEqual([
{ twitchAccount: "streamer_a", matchId: 1, timestamp: 1000 },
]);
});
});
describe("unassigning a cast", () => {
it("removes matchId from castedMatches and lockedMatches", () => {
const current = emptyCastedMatchesInfo();
current.castedMatches = [
{ twitchAccount: "streamer_a", matchId: 1 },
{ twitchAccount: "streamer_b", matchId: 2 },
];
current.lockedMatches = [{ twitchAccount: "streamer_a", matchId: 1 }];
const result = updatedCastedMatchesInfo(current, {
matchId: 1,
twitchAccount: null,
timestamp: 1000,
});
expect(result.castedMatches).toEqual([
{ twitchAccount: "streamer_b", matchId: 2 },
]);
expect(result.lockedMatches).toEqual([]);
});
it("does not modify castedMatchHistory", () => {
const current = emptyCastedMatchesInfo();
current.castedMatchHistory = [
{ twitchAccount: "streamer_a", matchId: 1, timestamp: 500 },
];
const result = updatedCastedMatchesInfo(current, {
matchId: 1,
twitchAccount: null,
timestamp: 1000,
});
expect(result.castedMatchHistory).toEqual([
{ twitchAccount: "streamer_a", matchId: 1, timestamp: 500 },
]);
});
});
});

View File

@@ -4,7 +4,11 @@ import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { weekNumberToDate } from "~/utils/dates";
import { SHORT_NANOID_LENGTH } from "~/utils/id";
import type { Tables, TournamentStageSettings } from "../../db/tables";
import type {
CastedMatchesInfo,
Tables,
TournamentStageSettings,
} from "../../db/tables";
import { assertUnreachable } from "../../utils/types";
import { MapPool } from "../map-list-generator/core/map-pool";
import * as Seasons from "../mmr/core/Seasons";
@@ -386,3 +390,46 @@ export function getBracketProgressionLabel(
return prefix;
}
/**
* Returns a new `CastedMatchesInfo` with the cast assignment applied. Tracks history of streamed set per channel.
* Deduplicates history by `matchId` so that correcting a wrong channel replaces the previous entry.
*
*/
export function updatedCastedMatchesInfo(
current: CastedMatchesInfo,
args: { matchId: number; twitchAccount: string | null; timestamp: number },
): CastedMatchesInfo {
const { matchId, twitchAccount, timestamp } = args;
if (twitchAccount === null) {
return {
...current,
castedMatches: current.castedMatches.filter(
(cm) => cm.matchId !== matchId,
),
lockedMatches: current.lockedMatches.filter(
(lm) => lm.matchId !== matchId,
),
};
}
const existingHistory = current.castedMatchHistory ?? [];
return {
...current,
castedMatches: current.castedMatches
.filter(
(cm) =>
// currently a match can only be streamed by one account
// and a cast can only stream one match at a time
// these can change in the future
cm.matchId !== matchId && cm.twitchAccount !== twitchAccount,
)
.concat([{ twitchAccount, matchId }]),
lockedMatches: current.lockedMatches.filter((lm) => lm.matchId !== matchId),
castedMatchHistory: existingHistory
.filter((entry) => entry.matchId !== matchId)
.concat([{ twitchAccount, matchId, timestamp }]),
};
}

View File

@@ -274,6 +274,10 @@
gap: var(--s-2);
align-items: center;
font-weight: var(--weight-semi);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.streamViewerCount {
@@ -492,3 +496,10 @@
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
}
.spoilerRevealContainer {
display: flex;
justify-content: center;
align-items: center;
padding: var(--s-12) 0;
}

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import { useUser } from "~/features/auth/core/user";
const SESSION_STORAGE_KEY = "spoilerFreeRevealed";
const listeners = new Set<() => void>();
function setRevealedIds(ids: number[]) {
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(ids));
for (const listener of listeners) {
listener();
}
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot() {
return sessionStorage.getItem(SESSION_STORAGE_KEY) ?? "[]";
}
function getServerSnapshot() {
return "[]";
}
export function useSpoilerFree() {
const user = useUser();
const raw = React.useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
let revealedIds: number[];
try {
revealedIds = JSON.parse(raw);
} catch {
revealedIds = [];
}
const isEnabled = Boolean(user?.preferences.spoilerFreeMode);
const isCensored = (tournamentId: number) =>
isEnabled && !revealedIds.includes(tournamentId);
const reveal = (tournamentId: number) => {
setRevealedIds([...revealedIds, tournamentId]);
};
const hide = (tournamentId: number) => {
setRevealedIds(revealedIds.filter((id) => id !== tournamentId));
};
return { isEnabled, isCensored, reveal, hide };
}

View File

@@ -0,0 +1,55 @@
import { logger } from "~/utils/logger";
import { getToken, purgeCachedToken } from "./token";
import { getTwitchEnvVars } from "./utils";
const MAX_RATE_LIMIT_RETRIES = 5;
const MAX_RATE_LIMIT_WAIT_MS = 60_000;
export async function twitchFetch(
url: string,
{ isRetry = false, rateLimitRetries = 0 } = {},
): Promise<Response> {
const { TWITCH_CLIENT_ID } = getTwitchEnvVars();
const token = await getToken();
const res = await fetch(url, {
headers: [
["Authorization", `Bearer ${token}`],
["Client-Id", TWITCH_CLIENT_ID],
],
});
if (res.status === 401 && !isRetry) {
purgeCachedToken();
return twitchFetch(url, { isRetry: true });
}
if (res.status === 429) {
if (rateLimitRetries >= MAX_RATE_LIMIT_RETRIES) {
throw new Error(
`Twitch API rate limited after ${MAX_RATE_LIMIT_RETRIES} retries`,
);
}
const resetHeader = res.headers.get("Ratelimit-Reset");
const resetTimestamp = resetHeader ? Number(resetHeader) : 0;
const waitMs = Math.max(resetTimestamp * 1000 - Date.now(), 1000);
if (waitMs > MAX_RATE_LIMIT_WAIT_MS) {
throw new Error(
`Twitch API rate limit reset too far in the future (${Math.ceil(waitMs / 1000)}s)`,
);
}
logger.warn(`Twitch rate limited, waiting ${Math.ceil(waitMs / 1000)}s`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
return twitchFetch(url, { rateLimitRetries: rateLimitRetries + 1 });
}
if (!res.ok) {
throw new Error(`Twitch API request failed with status: ${res.status}`);
}
return res;
}

View File

@@ -30,5 +30,33 @@ export const tokenResponseSchema = z.object({
token_type: z.string(),
});
export const usersSchema = z.object({
data: z.array(
z.object({
id: z.string(),
login: z.string(),
display_name: z.string(),
}),
),
});
export const videosSchema = z.object({
data: z.array(
z.object({
id: z.string(),
user_id: z.string(),
user_login: z.string(),
title: z.string(),
created_at: z.string(),
duration: z.string(),
view_count: z.number(),
type: z.string(),
}),
),
pagination: z.object({ cursor: z.string().nullish() }),
});
export type StreamsResponse = z.infer<typeof streamsSchema>;
export type RawStream = Unpacked<z.infer<typeof streamsSchema>["data"]>;
export type UsersResponse = z.infer<typeof usersSchema>;
export type RawVideo = Unpacked<z.infer<typeof videosSchema>["data"]>;

View File

@@ -2,9 +2,8 @@ import { cachified } from "@epic-web/cachified";
import { cache } from "~/utils/cache.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { logger } from "~/utils/logger";
import { twitchFetch } from "./fetch";
import { type RawStream, type StreamsResponse, streamsSchema } from "./schemas";
import { getToken, purgeCachedToken } from "./token";
import { getTwitchEnvVars } from "./utils";
// const STREAMS_MOCK = [
// {
@@ -112,7 +111,7 @@ async function getAllStreams() {
if (count === 50) {
throw new Error("Stuck getting streams");
}
const { data, pagination } = await getStreamsChunk({ cursor });
const { data, pagination } = await getStreamsChunk(cursor);
result.push(
// filter to ensure each streamer appears only once
@@ -132,39 +131,13 @@ async function getAllStreams() {
}
}
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(
async function getStreamsChunk(cursor?: string): Promise<StreamsResponse> {
const res = await twitchFetch(
`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);

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { parseTwitchDuration } from "./vods";
describe("parseTwitchDuration()", () => {
it("parses hours, minutes and seconds", () => {
expect(parseTwitchDuration("1h2m3s")).toBe(3723);
});
it("parses hours only", () => {
expect(parseTwitchDuration("2h")).toBe(7200);
});
it("parses minutes only", () => {
expect(parseTwitchDuration("45m")).toBe(2700);
});
it("parses seconds only", () => {
expect(parseTwitchDuration("30s")).toBe(30);
});
it("parses hours and minutes without seconds", () => {
expect(parseTwitchDuration("1h30m")).toBe(5400);
});
it("parses hours and seconds without minutes", () => {
expect(parseTwitchDuration("2h15s")).toBe(7215);
});
it("parses minutes and seconds without hours", () => {
expect(parseTwitchDuration("5m10s")).toBe(310);
});
it("returns 0 for empty string", () => {
expect(parseTwitchDuration("")).toBe(0);
});
it("parses large values", () => {
expect(parseTwitchDuration("99h59m59s")).toBe(359999);
});
});

View File

@@ -0,0 +1,79 @@
import * as R from "remeda";
import { twitchFetch } from "./fetch";
import {
type RawVideo,
type UsersResponse,
usersSchema,
videosSchema,
} from "./schemas";
export async function getUsersByLogin(
logins: string[],
): Promise<UsersResponse["data"]> {
if (logins.length === 0) return [];
const results: UsersResponse["data"] = [];
for (const batch of R.chunk(logins, 100)) {
const params = batch.map((l) => `login=${encodeURIComponent(l)}`).join("&");
const res = await twitchFetch(
`https://api.twitch.tv/helix/users?${params}`,
);
const parsed = usersSchema.safeParse(await res.json());
if (!parsed.success) {
throw new Error(
`Twitch users schema validation failed: ${parsed.error.message}`,
);
}
results.push(...parsed.data.data);
}
return results;
}
export async function getArchiveVideos(userId: string): Promise<RawVideo[]> {
const results: RawVideo[] = [];
let cursor: string | undefined;
while (true) {
const url = new URL("https://api.twitch.tv/helix/videos");
url.searchParams.set("user_id", userId);
url.searchParams.set("type", "archive");
url.searchParams.set("first", "100");
if (cursor) {
url.searchParams.set("after", cursor);
}
const res = await twitchFetch(url.toString());
const parsed = videosSchema.safeParse(await res.json());
if (!parsed.success) {
throw new Error(
`Twitch videos schema validation failed: ${parsed.error.message}`,
);
}
results.push(...parsed.data.data);
if (!parsed.data.pagination.cursor) break;
cursor = parsed.data.pagination.cursor;
}
return results;
}
const DURATION_REGEX = /(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/;
export function parseTwitchDuration(duration: string): number {
const match = DURATION_REGEX.exec(duration);
if (!match) return 0;
const hours = Number(match[1] ?? 0);
const minutes = Number(match[2] ?? 0);
const seconds = Number(match[3] ?? 0);
return hours * 3600 + minutes * 60 + seconds;
}

View File

@@ -0,0 +1,12 @@
import * as TournamentMatchVodRepository from "../features/tournament-bracket/TournamentMatchVodRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const DeleteObsoleteMatchVodsRoutine = new Routine({
name: "DeleteObsoleteMatchVods",
func: async () => {
const { numDeletedRows } =
await TournamentMatchVodRepository.deleteObsolete();
logger.info(`Deleted ${numDeletedRows} obsolete match vods`);
},
});

View File

@@ -1,4 +1,5 @@
import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
@@ -8,6 +9,7 @@ import { NotifySeasonStartRoutine } from "./notifySeasonStart";
import { SetOldGroupsAsInactiveRoutine } from "./setOldGroupsAsInactive";
import { SyncLiveStreamsRoutine } from "./syncLiveStreams";
import { SyncSplatoonRotationsRoutine } from "./syncSplatoonRotations";
import { SyncTournamentVodsRoutine } from "./syncTournamentVods";
import { UpdatePatreonDataRoutine } from "./updatePatreonData";
/** List of Routines that should occur hourly at XX:00 */
@@ -17,6 +19,7 @@ export const everyHourAt00 = [
NotifyCheckInStartRoutine,
NotifyScrimStartingSoonRoutine,
SyncSplatoonRotationsRoutine,
SyncTournamentVodsRoutine,
];
/** List of Routines that should occur hourly at XX:30 */
@@ -27,6 +30,7 @@ export const everyHourAt30 = [
/** List of Routines that should occur daily */
export const daily = [
DeleteObsoleteMatchVodsRoutine,
DeleteOldNotificationsRoutine,
CloseExpiredCommissionsRoutine,
DeleteOrphanArtTagsRoutine,

View File

@@ -0,0 +1,209 @@
import type { Insertable } from "kysely";
import type { DB } from "~/db/tables";
import * as TournamentMatchVodRepository from "~/features/tournament-bracket/TournamentMatchVodRepository.server";
import { hasTwitchEnvVars } from "~/modules/twitch/utils";
import {
getArchiveVideos,
getUsersByLogin,
parseTwitchDuration,
} from "~/modules/twitch/vods";
import { logger } from "~/utils/logger";
import { Routine } from "./routine.server";
const VOD_TIMESTAMP_OFFSET_SECONDS = 180;
const BRACKET_RESET_OFFSET_SECONDS = 0;
export const SyncTournamentVodsRoutine = new Routine({
name: "SyncTournamentVods",
func: syncTournamentVods,
});
async function syncTournamentVods() {
if (!hasTwitchEnvVars()) return;
const tournaments =
await TournamentMatchVodRepository.findFinalizedTournamentsNeedingVods();
for (const tournament of tournaments) {
await processOneTournament(tournament.id);
}
}
export async function processOneTournament(tournamentId: number) {
const matches =
await TournamentMatchVodRepository.findMatchesWithStartedAt(tournamentId);
if (matches.length === 0) return;
const matchesById = new Map(matches.map((m) => [m.id, m]));
const loginToTwitchId = new Map<string, string>();
const vods: Insertable<DB["TournamentMatchVod"]>[] = [];
// Player stream VODs
const streamers =
await TournamentMatchVodRepository.findStreamersByTournamentId(
tournamentId,
);
if (streamers.length > 0) {
const twitchUsers = await getUsersByLogin(
streamers.map((s) => s.twitchAccount),
);
for (const u of twitchUsers) {
loginToTwitchId.set(u.login.toLowerCase(), u.id);
}
const participantsByMatch = new Map(
matches.map((m) => [m.id, new Set(m.participants.map((p) => p.userId))]),
);
const streamerDbUserIds = new Map(
streamers
.filter((s) => s.userId !== null)
.map((s) => [s.twitchAccount.toLowerCase(), s.userId!]),
);
for (const streamer of streamers) {
const twitchUserId = loginToTwitchId.get(
streamer.twitchAccount.toLowerCase(),
);
if (!twitchUserId) continue;
const videos = await fetchArchiveVideos(
twitchUserId,
streamer.twitchAccount,
);
if (!videos) continue;
const dbUserId =
streamerDbUserIds.get(streamer.twitchAccount.toLowerCase()) ?? null;
for (const match of matches) {
if (!match.startedAt) continue;
if (dbUserId !== null) {
const matchParticipants = participantsByMatch.get(match.id);
if (!matchParticipants?.has(dbUserId)) continue;
}
const vodMatch = findMatchingVod(match.startedAt, match, videos);
if (!vodMatch) continue;
vods.push({
matchId: match.id,
userId: dbUserId,
platform: "TWITCH",
account: streamer.twitchAccount,
...vodMatch,
});
}
}
}
// Cast stream VODs
const castedMatchHistory =
await TournamentMatchVodRepository.findCastedMatchHistoryByTournamentId(
tournamentId,
);
if (castedMatchHistory.length > 0) {
const castMatchesByAccount = new Map<string, Set<number>>();
for (const entry of castedMatchHistory) {
if (!castMatchesByAccount.has(entry.twitchAccount)) {
castMatchesByAccount.set(entry.twitchAccount, new Set());
}
castMatchesByAccount.get(entry.twitchAccount)!.add(entry.matchId);
}
const newCastLogins = [...castMatchesByAccount.keys()].filter(
(account) => !loginToTwitchId.has(account.toLowerCase()),
);
if (newCastLogins.length > 0) {
const newUsers = await getUsersByLogin(newCastLogins);
for (const u of newUsers) {
loginToTwitchId.set(u.login.toLowerCase(), u.id);
}
}
const addedVodKeys = new Set(vods.map((v) => `${v.matchId}-${v.account}`));
for (const [account, matchIds] of castMatchesByAccount) {
const twitchUserId = loginToTwitchId.get(account.toLowerCase());
if (!twitchUserId) continue;
const videos = await fetchArchiveVideos(twitchUserId, account);
if (!videos) continue;
for (const matchId of matchIds) {
const match = matchesById.get(matchId);
if (!match?.startedAt) continue;
const vodKey = `${matchId}-${account}`;
if (addedVodKeys.has(vodKey)) continue;
const vodMatch = findMatchingVod(match.startedAt, match, videos);
if (!vodMatch) continue;
vods.push({
matchId,
userId: null,
platform: "TWITCH",
account,
...vodMatch,
});
addedVodKeys.add(vodKey);
}
}
}
if (vods.length > 0) {
await TournamentMatchVodRepository.insertMany(vods);
logger.info(`Inserted ${vods.length} VODs for tournament ${tournamentId}`);
}
}
async function fetchArchiveVideos(twitchUserId: string, accountName: string) {
try {
const videos = await getArchiveVideos(twitchUserId);
return videos.length > 0 ? videos : null;
} catch (e) {
logger.warn(`Failed to fetch VODs for ${accountName}: ${e}`);
return null;
}
}
function findMatchingVod(
matchStartSeconds: number,
match: { stageType: string; groupNumber: number; roundNumber: number },
videos: NonNullable<Awaited<ReturnType<typeof fetchArchiveVideos>>>,
) {
for (const video of videos) {
const vodStartSeconds = Math.floor(
new Date(video.created_at).getTime() / 1000,
);
const vodDurationSeconds = parseTwitchDuration(video.duration);
const vodEndSeconds = vodStartSeconds + vodDurationSeconds;
if (
matchStartSeconds >= vodStartSeconds &&
matchStartSeconds <= vodEndSeconds
) {
const isBracketReset =
match.stageType === "double_elimination" &&
match.groupNumber === 3 &&
match.roundNumber === 2;
const offsetSeconds = isBracketReset
? BRACKET_RESET_OFFSET_SECONDS
: VOD_TIMESTAMP_OFFSET_SECONDS;
const timestampSeconds =
matchStartSeconds - vodStartSeconds + offsetSeconds;
return {
platformVideoId: video.id,
timestampSeconds,
viewCount: video.view_count,
};
}
}
return null;
}

View File

@@ -550,3 +550,10 @@ export const mySlugify = (name: string) => {
export const isCustomUrl = (value: string) => {
return Number.isNaN(Number(value));
};
export function vodUrl(vod: {
platformVideoId: string;
timestampSeconds: number;
}) {
return `https://www.twitch.tv/videos/${vod.platformVideoId}?t=${vod.timestampSeconds}s`;
}

Binary file not shown.

View File

@@ -216,6 +216,22 @@ erDiagram
User ||--o{ TournamentOrganizationBannedUser : banned_from
```
## Tournament VODs
```mermaid
erDiagram
TournamentStreamer }o--|| Tournament : tournament
TournamentStreamer }o--o| User : user
TournamentMatchVod }o--|| TournamentMatch : match
TournamentMatchVod }o--o| User : user
```
### Notes
- **TournamentStreamer** - Twitch accounts streaming a tournament; auto-populated when players/casters go live. `userId` is null for cast accounts not linked to a sendou.ink user. Unique on `(twitchAccount, tournamentId)`.
- **TournamentMatchVod** - Past broadcast VOD references for tournament matches with a timestamp offset to jump to the specific match.
## Videos
```mermaid
erDiagram

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,17 +1,25 @@
import type { Page } from "@playwright/test";
import {
clockFormatSchema,
disableBuildAbilitySortingSchema,
spoilerFreeModeSchema,
} from "~/features/settings/settings-schemas";
import {
expect,
impersonate,
isNotVisible,
navigate,
seed,
test,
waitForPOSTResponse,
} from "~/utils/playwright";
import { createFormHelpers } from "~/utils/playwright-form";
import { CALENDAR_PAGE, SETTINGS_PAGE } from "~/utils/urls";
import {
CALENDAR_PAGE,
SETTINGS_PAGE,
tournamentBracketsPage,
tournamentResultsPage,
} from "~/utils/urls";
test.describe("Settings", () => {
test("updates 'disableBuildAbilitySorting'", async ({ page }) => {
@@ -84,3 +92,73 @@ test.describe("Settings", () => {
expect(newTime).toContain(":");
});
});
const enableSpoilerFreeMode = async (page: Page) => {
await navigate({ page, url: SETTINGS_PAGE });
const form = createFormHelpers(page, spoilerFreeModeSchema);
await waitForPOSTResponse(page, () => form.check("newValue"));
};
test.describe("Spoiler-free mode", () => {
const FINALIZED_TOURNAMENT_ID = 7;
test("censors bracket and reveals on click", async ({ page }) => {
await seed(page, "FINALIZED_BRACKET");
await impersonate(page);
await enableSpoilerFreeMode(page);
await navigate({
page,
url: tournamentBracketsPage({ tournamentId: FINALIZED_TOURNAMENT_ID }),
});
// bracket is censored — "Show results" button visible
const showResultsButton = page.getByRole("button", {
name: "Show results",
});
await expect(showResultsButton).toBeVisible();
// later rounds (SF/Finals) show "???" for team names
await expect(page.getByText("???").first()).toBeVisible();
// click "Show results" to reveal
await showResultsButton.click();
// after reveal, "Hide results" button appears
await expect(
page.getByRole("button", { name: "Hide results" }),
).toBeVisible();
// "???" no longer present
await isNotVisible(page.getByText("???"));
// navigate to results page — sessionStorage reveal carries over
await navigate({
page,
url: tournamentResultsPage(FINALIZED_TOURNAMENT_ID),
});
await expect(page.getByTestId("result-team-name").first()).toBeVisible();
});
test("results page is censored and can be revealed", async ({ page }) => {
await seed(page, "FINALIZED_BRACKET");
await impersonate(page);
await enableSpoilerFreeMode(page);
await navigate({
page,
url: tournamentResultsPage(FINALIZED_TOURNAMENT_ID),
});
// results are censored
const showResultsButton = page.getByRole("button", {
name: "Show results",
});
await expect(showResultsButton).toBeVisible();
await isNotVisible(page.getByTestId("result-team-name"));
// reveal
await showResultsButton.click();
await expect(page.getByTestId("result-team-name").first()).toBeVisible();
});
});

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "Lav bane-liste",
"maps.halfSz": "50% DD",
@@ -391,5 +392,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Note: Hvis du ændrer dit holds navn, så kan andre hold overtage det tidligere holdnavn og URL-adresse.",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "Arenen-Liste erstellen",
"maps.halfSz": "50% Herrschaft",
@@ -391,5 +392,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Hinweis: Wenn du den Namen deines Teams änderst, können andere Teams den Namen und und die URL für sich beanspruchen.",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "View all",
"actions.hide": "Hide",
"actions.settings": "Settings",
"actions.reveal": "Reveal",
"noResults": "No results",
"maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ",
@@ -391,5 +392,7 @@
"header.parameter": "Parameter",
"weaponArt.title": "Community Art",
"tier.tentative": "Tentative {{tierName}}-tier (based on series history)",
"tier.confirmed": "{{tierName}}-tier tournament"
"tier.confirmed": "{{tierName}}-tier tournament",
"spoilerFree.showResults": "Show results",
"spoilerFree.hideResults": "Hide results"
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "Builds: Disable automatic ability sorting",
"labels.disallowScrimPickupsFromUntrusted": "Disallow scrim pickups from non-friends",
"labels.noScreen": "[Accessibility] Avoid Splattercolor Screen",
"labels.spoilerFreeMode": "Spoiler-free mode",
"bottomTexts.name": "Note that if you change your team's name then someone else can claim the name and URL for their team",
"bottomTexts.tag": "Typically used before in-game name to indicate membership of a team (e.g. [TAG] PlayerName)",
"bottomTexts.disableBuildAbilitySorting": "Outside of your profile page, build abilities are sorted so that same abilities are next to each other. This setting allows you to see the abilities in the order they were authored everywhere.",
"bottomTexts.disallowScrimPickupsFromUntrusted": "Only applies if you are in the lobby as group leader. Other group leaders can still pick you up.",
"bottomTexts.noScreen": "Affects tournaments, scrims and SendouQ",
"bottomTexts.spoilerFreeMode": "Hides tournament results from the last week",
"options.clockFormat.auto": "Automatic",
"options.clockFormat.24h": "24-hour",
"options.clockFormat.12h": "12-hour",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "Ocultar",
"actions.settings": "Ajustes",
"actions.reveal": "",
"noResults": "Sin resultados",
"maps.createMapList": "Crear lista de mapas",
"maps.halfSz": "50% Pintazonas",
@@ -393,5 +394,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "Tier {{tierName}} provisional",
"tier.confirmed": "Tier {{tierName}} confirmado"
"tier.confirmed": "Tier {{tierName}} confirmado",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "Builds: Desactivar orden automático de potenciadores",
"labels.disallowScrimPickupsFromUntrusted": "No permitir invitaciones de usuarios no verificados",
"labels.noScreen": "[Accesibilidad] Evitar Pantintalla",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome",
"bottomTexts.tag": "Normalmente se usa antes del nombre en el juego para indicar pertenencia a un equipo (ej. [TAG] NombreJugador)",
"bottomTexts.disableBuildAbilitySorting": "Fuera de tu perfil, los potenciadores se agrupan. Activa esta opción para verlos siempre en el orden en que los creaste.",
"bottomTexts.disallowScrimPickupsFromUntrusted": "Solo aplica si estás en el lobby como líder de grupo. Otros líderes de grupo aún pueden añadirte.",
"bottomTexts.noScreen": "Afecta a torneos, scrims y SendouQ.",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "Automático",
"options.clockFormat.24h": "24 horas",
"options.clockFormat.12h": "12 horas",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "Crear lista de escenarios",
"maps.halfSz": "50% Pintazonas",
@@ -393,5 +394,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "Créer une liste de stages",
"maps.halfSz": "50% DdZ",
@@ -393,5 +394,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Veuillez noter que si vous changer le nom de l'équipe, quelqu'un d'autre pourra s'emparer de l'ancien nom et URL",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "Aucun résultats",
"maps.createMapList": "Créer une liste de stages",
"maps.halfSz": "50% DdZ",
@@ -393,5 +394,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Veuillez noter que si vous changer le nom de l'équipe, quelqu'un d'autre pourra s'emparer de l'ancien nom et URL",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "יצירת רשימת מפות",
"maps.halfSz": "50% SZ",
@@ -392,5 +393,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "שימו לב שאם תשנו את שם הצוות שלכם, מישהו אחר יוכל לקחת בעלות על השם ועל כתובת האתר עבור הצוות שלו",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "Crea lista scenari",
"maps.halfSz": "50% ZS",
@@ -393,5 +394,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Nota che se cambi il nome del team, qualcun altro può assumere nome e URL per il proprio team",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "ステージ一覧を作る",
"maps.halfSz": "ガチエリア (2ヶ所)",
@@ -387,5 +388,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "注意: チーム名を変更した場合、他のプレイヤーが変更前の名前と URL を別のチームのために使用することができるようになります。",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "맵 목록 생성",
"maps.halfSz": "에어리어 50%",
@@ -387,5 +388,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "Maak levellijst",
"maps.halfSz": "50% SZ",
@@ -391,5 +392,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "Stwórz liste map",
"maps.halfSz": "50% SZ",
@@ -394,5 +395,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Uwaga: Jeśli zmienisz imię drużyny, ktoś inny może użyć twoje stare imię i URL",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "Criar lista de mapas",
"maps.halfSz": "50% Zones",
@@ -393,5 +394,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Lembre-se que se você mudar o nome do seu time, alguém pode resgatar o nome e o URL para o time dele(a)",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "Нет результатов",
"maps.createMapList": "Создать список карт",
"maps.halfSz": "50% Зон",
@@ -394,5 +395,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "Обратите внимание, что если вы измените название команды, то кто-то другой может забрать себе URL и название для своей команды",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -147,6 +147,7 @@
"actions.viewAll": "",
"actions.hide": "",
"actions.settings": "",
"actions.reveal": "",
"noResults": "",
"maps.createMapList": "创建地图列表",
"maps.halfSz": "50%为真格区域",
@@ -387,5 +388,7 @@
"header.parameter": "",
"weaponArt.title": "",
"tier.tentative": "",
"tier.confirmed": ""
"tier.confirmed": "",
"spoilerFree.showResults": "",
"spoilerFree.hideResults": ""
}

View File

@@ -8,11 +8,13 @@
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"bottomTexts.name": "请注意如果您更改了队名那么其他人便可以使用之前的队名和URL了。",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -0,0 +1,22 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/*sql*/ `
create table "TournamentMatchVod" (
"id" integer primary key autoincrement,
"matchId" integer not null references "TournamentMatch"("id"),
"userId" integer references "User"("id"),
"platform" text not null,
"account" text not null,
"platformVideoId" text not null,
"timestampSeconds" integer not null,
"viewCount" integer not null
) strict
`,
).run();
db.prepare(
/*sql*/ `create unique index "tournament_match_vod_match_id_account" on "TournamentMatchVod"("matchId", "account")`,
).run();
})();
}

View File

@@ -0,0 +1,15 @@
// usage: npx tsx ./scripts/sync-tournament-vods.ts <tournamentId>
import "dotenv/config";
import { processOneTournament } from "~/routines/syncTournamentVods";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
const tournamentId = Number(process.argv[2]?.trim());
invariant(
tournamentId && !Number.isNaN(tournamentId),
"tournament id is required (argument 1)",
);
logger.info(`Syncing VODs for tournament ${tournamentId}...`);
await processOneTournament(tournamentId);
logger.info("Done");