sendou.ink/app/features/sendouq-streams/core/streams.server.ts
2026-03-07 17:23:32 +02:00

196 lines
5.5 KiB
TypeScript

import cachified from "@epic-web/cachified";
import * as R from "remeda";
import {
COMBINED_STREAMS_KEY,
type SidebarStream,
} from "~/features/core/streams/streams.server";
import {
cachedFullUserLeaderboard,
type UserLeaderboardWithAdditionsItem,
} from "~/features/leaderboards/core/leaderboards.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { TIERS, type TierName } from "~/features/mmr/mmr-constants";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as QStreamsRepository from "~/features/sendouq-streams/QStreamsRepository.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import { logger } from "~/utils/logger";
import { navIconUrl, SENDOUQ_STREAMS_PAGE, tierImageUrl } from "~/utils/urls";
import { SENDOUQ_STREAMS_KEY } from "../q-streams-constants";
export function cachedStreams() {
const season = Seasons.currentOrPrevious()!;
return cachified({
key: SENDOUQ_STREAMS_KEY,
cache: cache,
ttl: ttl(IN_MILLISECONDS.HALF_HOUR),
async getFreshValue() {
return streamedMatches({
matchPlayers: await QStreamsRepository.activeMatchPlayers(),
leaderboard: await cachedFullUserLeaderboard(season.nth),
}).sort((a, b) => {
const aTierIndex = TIERS.findIndex(
(tier) => tier.name === a.tier?.name,
);
const bTierIndex = TIERS.findIndex(
(tier) => tier.name === b.tier?.name,
);
// missing tiers sorted last
if (aTierIndex === -1 && bTierIndex !== -1) {
return 1;
}
if (aTierIndex !== -1 && bTierIndex === -1) {
return -1;
}
// sort by base tier
if (aTierIndex !== bTierIndex) {
return aTierIndex - bTierIndex;
}
// if base tier is the same, sort by plus
if (a.tier?.isPlus !== b.tier?.isPlus) {
return a.tier?.isPlus ? -1 : 1;
}
// if tier is the same, sort by viewer count
return b.stream.viewerCount - a.stream.viewerCount;
});
},
});
}
export function refreshStreamsCache() {
cache.delete(SENDOUQ_STREAMS_KEY);
cache.delete(COMBINED_STREAMS_KEY);
void cachedStreams().catch((err) =>
logger.error(`Failed to refresh cache: ${err}`),
);
}
function streamedMatches({
matchPlayers,
leaderboard,
}: {
matchPlayers: QStreamsRepository.ActiveMatchPlayersItem[];
leaderboard: UserLeaderboardWithAdditionsItem[];
}) {
return matchPlayers.flatMap((player) => {
if (!player.streamTwitch) {
return [];
}
const leaderboardEntry = leaderboard.find(
(entry) => entry.id === player.user?.id,
);
return {
stream: {
thumbnailUrl: player.streamThumbnailUrl,
twitchUserName: player.streamTwitch,
viewerCount: player.streamViewerCount,
},
match: {
id: player.groupMatchId,
createdAt: player.groupMatchCreatedAt,
},
user: {
...player.user!,
twitch: player.user!.twitch!,
},
weaponSplId: leaderboardEntry?.weaponSplId,
tier: leaderboardEntry?.tier,
};
});
}
export type SendouQSidebarEntry = {
sidebarStream: SidebarStream;
tier: { name: TierName; isPlus: boolean } | null;
twitchUsernames: string[];
};
export async function getSendouQSidebarStreams(): Promise<
SendouQSidebarEntry[]
> {
const streams = await cachedStreams();
const matchIdToStream = R.groupBy(streams, (s) => s.match.id);
const entries: SendouQSidebarEntry[] = [];
for (const [matchIdStr, matchStreams] of Object.entries(matchIdToStream)) {
const matchId = Number(matchIdStr);
const firstStream = matchStreams[0];
const matchGroups = SendouQ.groups.filter((g) => g.matchId === matchId);
const averageTier = calculateAverageTierForMatch(matchGroups);
const twitchUsernames = matchStreams
.map((s) => s.stream.twitchUserName)
.filter((t): t is string => t !== null);
entries.push({
sidebarStream: {
id: `sendouq-${matchId}`,
name: `Match #${matchId}`,
imageUrl: averageTier
? `${tierImageUrl(averageTier.name)}.png`
: `${navIconUrl("sendouq")}.png`,
overlayIconUrl: averageTier
? `${navIconUrl("sendouq")}.png`
: undefined,
url: SENDOUQ_STREAMS_PAGE,
subtitle: averageTier
? `${averageTier.name}${averageTier.isPlus ? "+" : ""}`
: "",
startsAt: firstStream.match.createdAt,
tier: null,
},
tier: averageTier,
twitchUsernames,
});
}
return entries.sort((a, b) => {
const aTierIndex = getTierIndexFromSubtitle(a.sidebarStream.subtitle);
const bTierIndex = getTierIndexFromSubtitle(b.sidebarStream.subtitle);
return aTierIndex - bTierIndex;
});
}
function calculateAverageTierForMatch(
matchGroups: (typeof SendouQ.groups)[number][],
): { name: TierName; isPlus: boolean } | null {
if (matchGroups.length !== 2) return null;
const allTiers = matchGroups
.map((g) => g.tier)
.filter((t): t is NonNullable<typeof t> => t !== null);
if (allTiers.length !== 2) return null;
const tierIndexSum = allTiers.reduce((sum, tier) => {
const baseIndex = TIERS.findIndex((t) => t.name === tier.name);
const indexWithPlus = baseIndex * 2 + (tier.isPlus ? 0 : 1);
return sum + indexWithPlus;
}, 0);
const averageIndex = tierIndexSum / 2;
const baseTierIndex = Math.floor(averageIndex / 2);
const isPlus = averageIndex % 2 < 1;
const tierName = TIERS[baseTierIndex]?.name ?? "IRON";
return { name: tierName, isPlus };
}
function getTierIndexFromSubtitle(subtitle: string): number {
const tierName = subtitle.replace("+", "");
const isPlus = subtitle.endsWith("+");
const baseIndex = TIERS.findIndex((t) => t.name === tierName);
if (baseIndex === -1) return 999;
return baseIndex * 2 + (isPlus ? 0 : 1);
}