sendou.ink/app/routines/syncTournamentVods.ts

228 lines
6.2 KiB
TypeScript

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.findTournamentsNeedingVodSync();
for (const tournament of tournaments) {
try {
const hadApiError = await processOneTournament(tournament.id);
if (!hadApiError) {
await TournamentMatchVodRepository.markVodSyncCompleted(tournament.id);
}
} catch (e) {
logger.warn(`Failed to sync VODs for tournament ${tournament.id}: ${e}`);
}
}
}
export async function processOneTournament(tournamentId: number) {
const matches =
await TournamentMatchVodRepository.findMatchesWithStartedAt(tournamentId);
if (matches.length === 0) return false;
let hadApiError = false;
const matchesById = new Map(matches.map((m) => [m.id, m]));
const loginToTwitchId = new Map<string, string>();
const vods: Insertable<DB["TournamentMatchVod"]>[] = [];
// Player stream VODs
const streamers =
await TournamentMatchVodRepository.findStreamersByTournamentId(
tournamentId,
);
if (streamers.length > 0) {
const twitchUsers = await getUsersByLogin(
streamers.map((s) => s.twitchAccount),
);
for (const u of twitchUsers) {
loginToTwitchId.set(u.login.toLowerCase(), u.id);
}
const participantsByMatch = new Map(
matches.map((m) => [m.id, new Set(m.participants.map((p) => p.userId))]),
);
const streamerDbUserIds = new Map(
streamers
.filter((s) => s.userId !== null)
.map((s) => [s.twitchAccount.toLowerCase(), s.userId!]),
);
for (const streamer of streamers) {
const dbUserId =
streamerDbUserIds.get(streamer.twitchAccount.toLowerCase()) ?? null;
// cast accounts (null userId) are handled via castedMatchHistory below
if (dbUserId === null) continue;
const twitchUserId = loginToTwitchId.get(
streamer.twitchAccount.toLowerCase(),
);
if (!twitchUserId) continue;
let videos: Awaited<ReturnType<typeof fetchArchiveVideos>>;
try {
videos = await fetchArchiveVideos(twitchUserId);
} catch (e) {
logger.warn(`Failed to fetch VODs for ${streamer.twitchAccount}: ${e}`);
hadApiError = true;
continue;
}
if (!videos) continue;
for (const match of matches) {
if (!match.startedAt) continue;
const matchParticipants = participantsByMatch.get(match.id);
if (!matchParticipants?.has(dbUserId)) continue;
const vodMatch = findMatchingVod(match.startedAt, match, videos);
if (!vodMatch) continue;
vods.push({
matchId: match.id,
userId: dbUserId,
platform: "TWITCH",
account: streamer.twitchAccount,
...vodMatch,
});
}
}
}
// Cast stream VODs
const castedMatchHistory =
await TournamentMatchVodRepository.findCastedMatchHistoryByTournamentId(
tournamentId,
);
if (castedMatchHistory.length > 0) {
const castMatchesByAccount = new Map<string, Set<number>>();
for (const entry of castedMatchHistory) {
if (!castMatchesByAccount.has(entry.twitchAccount)) {
castMatchesByAccount.set(entry.twitchAccount, new Set());
}
castMatchesByAccount.get(entry.twitchAccount)!.add(entry.matchId);
}
const newCastLogins = [...castMatchesByAccount.keys()].filter(
(account) => !loginToTwitchId.has(account.toLowerCase()),
);
if (newCastLogins.length > 0) {
const newUsers = await getUsersByLogin(newCastLogins);
for (const u of newUsers) {
loginToTwitchId.set(u.login.toLowerCase(), u.id);
}
}
const addedVodKeys = new Set(vods.map((v) => `${v.matchId}-${v.account}`));
for (const [account, matchIds] of castMatchesByAccount) {
const twitchUserId = loginToTwitchId.get(account.toLowerCase());
if (!twitchUserId) continue;
let videos: Awaited<ReturnType<typeof fetchArchiveVideos>>;
try {
videos = await fetchArchiveVideos(twitchUserId);
} catch (e) {
logger.warn(`Failed to fetch VODs for ${account}: ${e}`);
hadApiError = true;
continue;
}
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}`);
}
return hadApiError;
}
async function fetchArchiveVideos(twitchUserId: string) {
const videos = await getArchiveVideos(twitchUserId);
return videos.length > 0 ? videos : null;
}
function findMatchingVod(
matchStartSeconds: number,
match: { stageType: string; groupNumber: number; roundNumber: number },
videos: NonNullable<Awaited<ReturnType<typeof fetchArchiveVideos>>>,
) {
for (const video of videos) {
const vodStartSeconds = Math.floor(
new Date(video.created_at).getTime() / 1000,
);
const vodDurationSeconds = parseTwitchDuration(video.duration);
const vodEndSeconds = vodStartSeconds + vodDurationSeconds;
if (
matchStartSeconds >= vodStartSeconds &&
matchStartSeconds <= vodEndSeconds
) {
const isBracketReset =
match.stageType === "double_elimination" &&
match.groupNumber === 3 &&
match.roundNumber === 2;
const offsetSeconds = isBracketReset
? BRACKET_RESET_OFFSET_SECONDS
: VOD_TIMESTAMP_OFFSET_SECONDS;
const timestampSeconds =
matchStartSeconds - vodStartSeconds + offsetSeconds;
return {
platformVideoId: video.id,
timestampSeconds,
viewCount: video.view_count,
};
}
}
return null;
}