Real streams initial

This commit is contained in:
Kalle 2026-01-25 11:46:37 +02:00
parent bf74c18198
commit 96c62e547b
7 changed files with 160 additions and 34 deletions

View File

@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { href, Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
import type { loader as sidebarLoader } from "~/features/sidebar/routes/sidebar";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
import { mySlugify, navIconUrl, SETTINGS_PAGE, userPage } from "~/utils/urls";
@ -271,10 +272,8 @@ function MenuOverlay({
{stream.subtitle}
</span>
) : null}
{stream.badge ? (
<span className={styles.streamItemBadge}>
{stream.badge}
</span>
{stream.startsAt < Date.now() ? (
<span className={styles.streamItemBadge}>LIVE</span>
) : null}
</div>
</div>

View File

@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { Link, useFetcher, useLocation, useMatches } from "react-router";
import { useUser } from "~/features/auth/core/user";
import type { loader as sidebarLoader } from "~/features/sidebar/routes/sidebar";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl, SETTINGS_PAGE, userPage } from "~/utils/urls";
@ -185,6 +186,7 @@ export function Layout({
const location = useLocation();
const sidebarData = useSidebarData();
const navOffset = useNavOffset();
const isMounted = useIsMounted();
const events = sidebarData?.events ?? [];
const matchStatus = sidebarData?.matchStatus;
@ -295,10 +297,12 @@ export function Layout({
{streams.map((stream) => (
<SideNavLink
key={stream.id}
to=""
to={stream.url}
imageUrl={stream.imageUrl}
subtitle={stream.subtitle}
badge={stream.badge}
badge={
isMounted && stream.startsAt < Date.now() ? "LIVE" : undefined
}
>
{stream.name}
</SideNavLink>

View File

@ -0,0 +1,83 @@
import { cachified } from "@epic-web/cachified";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { Status } from "~/modules/brackets-model";
import { cache, ttl } from "~/utils/cache.server";
import { tournamentStreamsPage } from "~/utils/urls";
const FIVE_MINUTES = 5 * 60 * 1000;
const MAX_STREAMS = 5;
export function clearLiveStreamsCache() {
cache.delete("live-tournament-streams");
}
export type SidebarStream = {
id: number;
name: string;
imageUrl: string;
url: string;
subtitle: string;
startsAt: number;
tier: TournamentTierNumber | null;
};
export function getLiveTournamentStreams(): Promise<SidebarStream[]> {
return cachified({
key: "live-tournament-streams",
cache,
ttl: ttl(FIVE_MINUTES),
async getFreshValue() {
const streams: SidebarStream[] = [];
for (const tournament of RunningTournaments.all) {
streams.push({
id: tournament.ctx.id,
name: tournament.ctx.name,
imageUrl: tournament.ctx.logoUrl,
url: tournamentStreamsPage(tournament.ctx.id),
subtitle: deriveCurrentRound(tournament),
startsAt: tournament.ctx.startTime.getTime(),
tier: tournament.ctx.tier,
});
}
return streams.sort(sortByTierAscending).slice(0, MAX_STREAMS);
},
});
}
// xxx: this could be moved to Tournament class
// xxx: not always reporting furthest round
function deriveCurrentRound(tournament: Tournament): string {
for (const bracket of tournament.brackets) {
if (bracket.preview) continue;
for (const match of bracket.data.match) {
const isActive =
match.status === Status.Ready || match.status === Status.Running;
const hasParticipants = match.opponent1 && match.opponent2;
const isNotFinished =
!match.opponent1?.result && !match.opponent2?.result;
if (isActive && hasParticipants && isNotFinished) {
const context = tournament.matchContextNamesById(match.id);
if (context?.roundNameWithoutMatchIdentifier) {
return context.roundNameWithoutMatchIdentifier;
}
}
}
return bracket.name;
}
return "";
}
function sortByTierAscending(a: SidebarStream, b: SidebarStream): number {
if (a.tier === null && b.tier === null) return 0;
if (a.tier === null) return 1;
if (b.tier === null) return -1;
return a.tier - b.tier;
}

View File

@ -1,5 +1,6 @@
import { href, type LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import { getLiveTournamentStreams } from "~/features/core/streams/streams.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
@ -40,7 +41,7 @@ export const loader = async (_args: LoaderFunctionArgs) => {
logoUrl: string | null;
} | null,
friends: [] as ReturnType<typeof resolveFriends>,
streams: getMockStreams(),
streams: await getLiveTournamentStreams(),
};
}
@ -98,7 +99,7 @@ export const loader = async (_args: LoaderFunctionArgs) => {
: null,
tournamentMatchStatus,
friends,
streams: getMockStreams(),
streams: await getLiveTournamentStreams(),
};
};
@ -220,29 +221,3 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
return result;
}
function getMockStreams() {
return [
{
id: 3,
name: "Paddling Pool 252",
imageUrl: "https://i.pravatar.cc/150?u=stream1",
subtitle: "Losers Finals",
badge: "LIVE",
},
{
id: 1,
name: "Splash Go!",
imageUrl:
"https://liquipedia.net/commons/images/7/73/Splash_Go_allmode.png",
subtitle: "Tomorrow, 9:00 AM",
},
{
id: 2,
name: "Area Cup",
imageUrl:
"https://pbs.twimg.com/profile_images/1830601967821017088/4SDZVKdj_400x400.jpg",
subtitle: "Saturday, 10 AM",
},
];
}

View File

@ -34,6 +34,10 @@ class RunningTournamentsRegistry {
return this.tournaments.get(tournamentId);
}
get all() {
return Array.from(this.tournaments.values());
}
has(tournamentId: number) {
return this.tournaments.has(tournamentId);
}

View File

@ -1,3 +1,4 @@
import { clearLiveStreamsCache } from "~/features/core/streams/streams.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
@ -139,10 +140,31 @@ export function clearAllTournamentDataCache() {
function syncTournamentToRegistry(tournament: Tournament) {
const isRunning = tournament.hasStarted && !tournament.everyBracketOver;
const wasInRegistry = RunningTournaments.has(tournament.ctx.id);
if (isRunning) {
RunningTournaments.add(tournament);
if (!wasInRegistry) {
clearLiveStreamsCache();
}
} else {
if (wasInRegistry) {
clearLiveStreamsCache();
}
RunningTournaments.remove(tournament.ctx.id);
}
}
async function primeRunningTournamentsCache() {
const tournamentIds = await TournamentRepository.findRunningTournamentIds();
for (const tournamentId of tournamentIds) {
const data = await tournamentData({ user: undefined, tournamentId });
if (!data) continue;
const tournament = new Tournament({ ...data, simulateBrackets: false });
syncTournamentToRegistry(tournament);
}
}
await primeRunningTournamentsCache();

View File

@ -1,3 +1,4 @@
import { sub } from "date-fns";
import { type Insertable, type NotNull, sql, type Transaction } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
@ -1252,3 +1253,41 @@ export function updateTournamentTier({
.where("id", "=", tournamentId)
.execute();
}
export async function findRunningTournamentIds() {
const now = new Date();
const oneDayAgo = sub(now, { days: 1 });
const rows = await db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select("Tournament.id")
.where("Tournament.isFinalized", "=", 0)
.where("CalendarEventDate.startTime", "<", dateToDatabaseTimestamp(now))
.where(
"CalendarEventDate.startTime",
">",
dateToDatabaseTimestamp(oneDayAgo),
)
.where((eb) =>
eb.exists(
eb
.selectFrom("TournamentStage")
.select("TournamentStage.id")
.whereRef("TournamentStage.tournamentId", "=", "Tournament.id"),
),
)
.where(
sql<number>`json_extract("Tournament"."settings", '$.isTest')`,
"is not",
1,
)
.execute();
return rows.map((row) => row.id);
}