diff --git a/.claude/skills/sendou-code-review/SKILL.md b/.claude/skills/sendou-code-review/SKILL.md index 33634c2bd..834914d09 100644 --- a/.claude/skills/sendou-code-review/SKILL.md +++ b/.claude/skills/sendou-code-review/SKILL.md @@ -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 " + ``` + 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 "` and `sqlite3 db.sqlite3 "PRAGMA index_info()"` 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 diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index b9804b401..e5eb26ad5 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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", diff --git a/app/db/tables.ts b/app/db/tables.ts index 823535c43..29bf91380 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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; + matchId: number; + userId: number | null; + platform: string; + account: string; + platformVideoId: string; + timestampSeconds: number; + viewCount: number; +} + export interface BanLog { id: GeneratedAlways; userId: number; @@ -1352,6 +1370,7 @@ export interface DB { TournamentBracketProgressionOverride: TournamentBracketProgressionOverride; TournamentOrganizationBannedUser: TournamentOrganizationBannedUser; TournamentStreamer: TournamentStreamer; + TournamentMatchVod: TournamentMatchVod; TrustRelationship: TrustRelationship; Friendship: Friendship; FriendRequest: FriendRequest; diff --git a/app/features/api-private/constants.ts b/app/features/api-private/constants.ts index 54a82da03..6f6982bba 100644 --- a/app/features/api-private/constants.ts +++ b/app/features/api-private/constants.ts @@ -6,4 +6,5 @@ export const SEED_VARIATIONS = [ "NZAP_IN_TEAM", "NO_SCRIMS", "NO_SQ_GROUPS", + "FINALIZED_BRACKET", ] as const; diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts index da8faf415..1e4d35d0b 100644 --- a/app/features/calendar/calendar-types.ts +++ b/app/features/calendar/calendar-types.ts @@ -52,6 +52,7 @@ export interface ShowcaseCalendarEvent extends CommonEvent { notShownMembersCount: number; div: string | null; } | null; + hasVods?: boolean; } export interface GroupedCalendarEvents { diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css index daf745133..c969cfaf7 100644 --- a/app/features/calendar/components/TournamentCard.module.css +++ b/app/features/calendar/components/TournamentCard.module.css @@ -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); diff --git a/app/features/calendar/components/TournamentCard.tsx b/app/features/calendar/components/TournamentCard.tsx index 72bf78c03..a90b5cd81 100644 --- a/app/features/calendar/components/TournamentCard.tsx +++ b/app/features/calendar/components/TournamentCard.tsx @@ -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({ ) : null} {isShowcase && tournament.firstPlacer ? ( - + ) : null}
+ {isShowcase && tournament.firstPlacer && isCensored(tournament.id) ? ( + reveal(tournament.id)} /> + ) : null} + {isShowcase && "hasVods" in tournament && tournament.hasVods ? ( +
📺 VODs
+ ) : null} {tournament.modes ? : null}
; + censored: boolean; }) { const { t } = useTranslation(["front"]); return (
- {firstPlacer.logoUrl ? ( + {!censored && firstPlacer.logoUrl ? ( - {firstPlacer.teamName} + {censored ? "???" : firstPlacer.teamName}
{t("front:showcase.card.winner")} @@ -176,11 +189,13 @@ function TournamentFirstPlacers({
{firstPlacer.members.map((member) => (
- {member.country ? : null} - {member.username}{" "} + {!censored && member.country ? ( + + ) : null} + {censored ? "???" : member.username}{" "}
))} - {firstPlacer.notShownMembersCount > 0 ? ( + {!censored && firstPlacer.notShownMembersCount > 0 ? (
+{firstPlacer.notShownMembersCount}
@@ -190,6 +205,21 @@ function TournamentFirstPlacers({ ); } +function SpoilerRevealPill({ onReveal }: { onReveal: () => void }) { + const { t } = useTranslation(["common"]); + + return ( + } + > + {t("common:actions.reveal")} + + ); +} + function ModesPill({ modes }: { modes: NonNullable }) { const size = 16; diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts index 847ba269d..c7c791027 100644 --- a/app/features/front-page/core/ShowcaseTournaments.server.ts +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -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 ? { diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 06ecf06c4..f05cf873d 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -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, diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx index 2b852c477..dc65df372 100644 --- a/app/features/settings/routes/settings.tsx +++ b/app/features/settings/routes/settings.tsx @@ -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 }) => } + +>; +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[]) { + 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().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 ?? []; +} diff --git a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx index e05a039a6..89b35568b 100644 --- a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx +++ b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx @@ -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(), diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx index a0025a1a3..369e93b27 100644 --- a/app/features/tournament-bracket/components/Bracket/Elimination.tsx +++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx @@ -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} /> diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx index 9f2bf7c31..0471ceeda 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.tsx +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -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) { > + ) : matchVods.length > 0 ? ( + + 📺 VOD + + } + > + + ) : null}
); @@ -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 (
m.username).join(", ")} + title={ + spoilerCensor === "full" + ? undefined + : team?.members.map((m) => m.username).join(", ") + } >
- {team?.seed} + {displayedSeed}
{showAvatar ? ( ) : null} @@ -267,7 +305,7 @@ function MatchRow({ invisible: !team, })} > - {team?.name ?? "???"} + {displayedName}
{" "}
{score()}
@@ -321,6 +359,58 @@ function MatchStreams({ match }: Pick) { ); } +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 ( +
+ {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 ( + + {user ? ( + <> + + {user.username} + {team?.name} + + ) : ( + {vod.account} + )} + + {vod.viewCount.toLocaleString()} views + + + ); + })} +
+ ); +} + function MatchTimer({ match, bracket }: Pick) { const [now, setNow] = React.useState(new Date()); const tournament = useTournament(); diff --git a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx index 32630323f..9f399d9a3 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx @@ -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 }) { ); })}
- + {censored ? null : ( + + )}
); })} diff --git a/app/features/tournament-bracket/components/Bracket/Swiss.tsx b/app/features/tournament-bracket/components/Bracket/Swiss.tsx index 7317626b2..fb2809b8f 100644 --- a/app/features/tournament-bracket/components/Bracket/Swiss.tsx +++ b/app/features/tournament-bracket/components/Bracket/Swiss.tsx @@ -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", + })} /> ); })}
- {teamWithBye ? ( + {teamWithBye && !(censored && round.number > 1) ? (
- + {censored ? null : ( + + )}
); diff --git a/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.test.ts b/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.test.ts new file mode 100644 index 000000000..bd553ea2f --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.test.ts @@ -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"); + }); +}); diff --git a/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.ts b/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.ts new file mode 100644 index 000000000..5dc4ed6b9 --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/useBracketSpoilerCensor.ts @@ -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, + ): 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"; +} diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 4573a94d7..81dc1284f 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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")} ) : null} + {censored ? ( + }> + {t("common:spoilerFree.showResults")} + + ) : canToggle ? ( + }> + {t("common:spoilerFree.hideResults")} + + ) : null} {showPrepareMapsButton ? ( // Error Boundary because preparing maps is optional, so no need to make the whole page inaccessible if it fails diff --git a/app/features/tournament-bracket/tournament-bracket.module.css b/app/features/tournament-bracket/tournament-bracket.module.css index 39c87d0ce..15da9945d 100644 --- a/app/features/tournament-bracket/tournament-bracket.module.css +++ b/app/features/tournament-bracket/tournament-bracket.module.css @@ -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); diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 459053e3b..424b1e0bc 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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>; 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().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") diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts index e9d9f3082..731759ea7 100644 --- a/app/features/tournament/loaders/to.$id.server.ts +++ b/app/features/tournament/loaders/to.$id.server.ts @@ -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> | 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, }); }; diff --git a/app/features/tournament/routes/to.$id.results.tsx b/app/features/tournament/routes/to.$id.results.tsx index 9386aafe3..5d1ed3e6e 100644 --- a/app/features/tournament/routes/to.$id.results.tsx +++ b/app/features/tournament/routes/to.$id.results.tsx @@ -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 ( +
+ reveal(tournament.ctx.id)} + icon={} + > + {t("common:spoilerFree.showResults")} + +
+ ); + } const standingsResult = Standings.tournamentStandings(tournament); diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 69a9c56dc..c7fbdbb0c 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -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; }; export function useTournament() { @@ -254,6 +256,10 @@ export function useTournamentPreparedMaps() { return useOutletContext().preparedMaps; } +export function useTournamentVods() { + return useOutletContext().vods; +} + function useTournamentChatLabels(tournament: Tournament) { const chatContext = useChatContext(); const setChatLabels = chatContext?.setChatLabels; diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index afe7feef7..cad0b1ab0 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -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", diff --git a/app/features/tournament/tournament-utils.test.ts b/app/features/tournament/tournament-utils.test.ts index 1d8a1f591..c81afec69 100644 --- a/app/features/tournament/tournament-utils.test.ts +++ b/app/features/tournament/tournament-utils.test.ts @@ -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 }, + ]); + }); + }); +}); diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index dffbb0590..c9017b864 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -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 }]), + }; +} diff --git a/app/features/tournament/tournament.module.css b/app/features/tournament/tournament.module.css index ced022be9..fcf6b6943 100644 --- a/app/features/tournament/tournament.module.css +++ b/app/features/tournament/tournament.module.css @@ -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; +} diff --git a/app/hooks/useSpoilerFree.ts b/app/hooks/useSpoilerFree.ts new file mode 100644 index 000000000..26aa1206e --- /dev/null +++ b/app/hooks/useSpoilerFree.ts @@ -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 }; +} diff --git a/app/modules/twitch/fetch.ts b/app/modules/twitch/fetch.ts new file mode 100644 index 000000000..fc877720f --- /dev/null +++ b/app/modules/twitch/fetch.ts @@ -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 { + 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; +} diff --git a/app/modules/twitch/schemas.ts b/app/modules/twitch/schemas.ts index 04e41f405..62b3c47d8 100644 --- a/app/modules/twitch/schemas.ts +++ b/app/modules/twitch/schemas.ts @@ -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; export type RawStream = Unpacked["data"]>; +export type UsersResponse = z.infer; +export type RawVideo = Unpacked["data"]>; diff --git a/app/modules/twitch/streams.ts b/app/modules/twitch/streams.ts index 4b83bdb0d..a27787a2d 100644 --- a/app/modules/twitch/streams.ts +++ b/app/modules/twitch/streams.ts @@ -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 { - const { TWITCH_CLIENT_ID } = getTwitchEnvVars(); - const token = await getToken(); - - const res = await fetch( +async function getStreamsChunk(cursor?: string): Promise { + 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); diff --git a/app/modules/twitch/vods.test.ts b/app/modules/twitch/vods.test.ts new file mode 100644 index 000000000..fce3e5ecc --- /dev/null +++ b/app/modules/twitch/vods.test.ts @@ -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); + }); +}); diff --git a/app/modules/twitch/vods.ts b/app/modules/twitch/vods.ts new file mode 100644 index 000000000..3a676b7f2 --- /dev/null +++ b/app/modules/twitch/vods.ts @@ -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 { + 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 { + 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; +} diff --git a/app/routines/deleteObsoleteMatchVods.ts b/app/routines/deleteObsoleteMatchVods.ts new file mode 100644 index 000000000..8e4b39ab0 --- /dev/null +++ b/app/routines/deleteObsoleteMatchVods.ts @@ -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`); + }, +}); diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index 01198bfb4..56ad38ca6 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -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, diff --git a/app/routines/syncTournamentVods.ts b/app/routines/syncTournamentVods.ts new file mode 100644 index 000000000..b6c42dcd0 --- /dev/null +++ b/app/routines/syncTournamentVods.ts @@ -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(); + const vods: Insertable[] = []; + + // 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>(); + 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>>, +) { + 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; +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index ddc7269d7..557bb9984 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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`; +} diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 0518e46e0..c485ca6d2 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/docs/dev/database-relations.md b/docs/dev/database-relations.md index 6608ce510..907d86791 100644 --- a/docs/dev/database-relations.md +++ b/docs/dev/database-relations.md @@ -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 diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 2b50bc48e..43373e50a 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 new file mode 100644 index 000000000..4e9862157 Binary files /dev/null and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 11bb450a1..919f74410 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index e4fdfc473..2791bc9d6 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 4b7caefc8..1182df1bf 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 6eec995ce..269b5d99c 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 7a93187be..521795527 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 919cad16f..a437e578f 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 91ce7387a..5ce6c7213 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -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(); + }); +}); diff --git a/locales/da/common.json b/locales/da/common.json index 1c02a43f3..078054c3b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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": "" } diff --git a/locales/da/forms.json b/locales/da/forms.json index 5c83fdf19..f765ac44f 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -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": "", diff --git a/locales/de/common.json b/locales/de/common.json index afdd6b29e..a486549cc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": "" } diff --git a/locales/de/forms.json b/locales/de/forms.json index 935777bb3..3e8616cab 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -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": "", diff --git a/locales/en/common.json b/locales/en/common.json index 387eafa82..f3f0694e2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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" } diff --git a/locales/en/forms.json b/locales/en/forms.json index 471a3237a..ff38a26a9 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index c4a919a4c..1c852ec44 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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": "" } diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 138ba449a..337ed8398 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -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", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index a9a735a77..2fa230dbc 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -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": "" } diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 2b9c2a5c5..1869fddc1 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -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": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 7ada8d38a..a8c064c13 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -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": "" } diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 0fab283ca..bce1360f3 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -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": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index eac3ede1f..ac268bdae 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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": "" } diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 5b9a37602..21f4905ef 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -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": "", diff --git a/locales/he/common.json b/locales/he/common.json index d84206423..6db52a6e8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "" } diff --git a/locales/he/forms.json b/locales/he/forms.json index 827b42e3a..df8814026 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -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": "", diff --git a/locales/it/common.json b/locales/it/common.json index 6461f54ba..eb9358411 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": "" } diff --git a/locales/it/forms.json b/locales/it/forms.json index 600dbd167..3ebe56146 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -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": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 71766155c..7b8228f19 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "" } diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 0dc092330..1127db5fd 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -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": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4ad6034c9..bfb90daa1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "" } diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 9424bffad..fe64b48dc 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -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": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8b72022a6..26af833b1 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": "" } diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 7e4ef3d7d..a3d5f9b6c 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -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": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 19371c21f..170533b2f 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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": "" } diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 6387728fc..9149f2586 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -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": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 4c9aa2792..908777b06 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -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": "" } diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 7ab7e262d..ad2f85eb5 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -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": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index a4fad8da1..ef5cab90c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "" } diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 89ed93850..952426f0f 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -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": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 8d59ddd7c..0cbb97949 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "" } diff --git a/locales/zh/forms.json b/locales/zh/forms.json index e1283f7d6..02619d2ea 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -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": "", diff --git a/migrations/127-tournament-match-vod.js b/migrations/127-tournament-match-vod.js new file mode 100644 index 000000000..0432339b0 --- /dev/null +++ b/migrations/127-tournament-match-vod.js @@ -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(); + })(); +} diff --git a/scripts/sync-tournament-vods.ts b/scripts/sync-tournament-vods.ts new file mode 100644 index 000000000..53a0e70d4 --- /dev/null +++ b/scripts/sync-tournament-vods.ts @@ -0,0 +1,15 @@ +// usage: npx tsx ./scripts/sync-tournament-vods.ts +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");