sendou.ink/app/features/sendouq-streams/core/streams.server.ts
Kalle e27260f88b Bye bye .png
... well mostly. Some places will still use it like PWA splash screens.
Otherwise browser support for .avif strong enough now to do this
now.
2026-03-29 16:48:47 +03: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)}.avif`
: `${navIconUrl("sendouq")}.avif`,
overlayIconUrl: averageTier
? `${navIconUrl("sendouq")}.avif`
: 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);
}