sendou.ink/app/features/vods/VodRepository.server.ts
Kalle f9192ff3d6
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Fix editing a Vod not showing the current PoV
2025-10-30 18:52:17 +02:00

275 lines
6.5 KiB
TypeScript

import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
import {
dateToDatabaseTimestamp,
dayMonthYearToDatabaseTimestamp,
} from "~/utils/dates";
import invariant from "~/utils/invariant";
import { type CommonUser, commonUserJsonObject } from "~/utils/kysely.server";
import { VODS_PAGE_BATCH_SIZE } from "./vods-constants";
import type { VideoBeingAdded, Vod } from "./vods-types";
import {
extractYoutubeIdFromVideoUrl,
hoursMinutesSecondsStringToSeconds,
} from "./vods-utils";
export async function findByUserId(userId: Tables["User"]["id"], limit = 100) {
return findVods({ userId, limit });
}
export async function findVods({
weapon,
mode,
stageId,
type,
userId,
limit = VODS_PAGE_BATCH_SIZE,
}: {
weapon?: MainWeaponId;
mode?: ModeShort;
stageId?: StageId;
type?: Tables["Video"]["type"];
userId?: number;
limit?: number;
}) {
let query = db
.selectFrom("Video")
.leftJoin("VideoMatch", "VideoMatch.videoId", "Video.id")
.leftJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
)
.leftJoin("User", "VideoMatchPlayer.playerUserId", "User.id")
.selectAll("Video")
.select(({ fn, ref, eb }) => [
sql<
Array<number>
>`json_group_array(distinct ${ref("VideoMatchPlayer.weaponSplId")})`
.$castTo<MainWeaponId[]>()
.as("weapons"),
fn
.agg("json_group_array", ["VideoMatchPlayer.playerName"])
.$castTo<string[]>()
.as("playerNames"),
jsonArrayFrom(
eb
.selectFrom("User")
.select([
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
])
.whereRef("User.id", "=", "VideoMatchPlayer.playerUserId"),
).as("players"),
]);
if (userId) {
query = query.where("User.id", "=", userId);
} else {
if (type) {
query = query.where("Video.type", "=", type);
}
if (mode) {
query = query.where("VideoMatch.mode", "=", mode);
}
if (stageId) {
query = query.where("VideoMatch.stageId", "=", stageId);
}
}
if (weapon) {
query = query.where(
"VideoMatchPlayer.weaponSplId",
"in",
weaponIdToArrayWithAlts(weapon),
);
}
const result = await query
.groupBy("Video.id")
.orderBy("Video.youtubeDate", "desc")
.limit(limit)
.execute();
const vods = result.map((value) => {
const { playerNames, players, ...vod } = value;
return {
...vod,
pov: playerNames[0] ?? players[0],
};
});
return vods;
}
export async function findVodById(id: Tables["Video"]["id"]) {
const videoQuery = db
.selectFrom("Video")
.select([
"id",
"title",
"youtubeDate",
"youtubeId",
"type",
"submitterUserId",
])
.where("Video.id", "=", id);
const video = await videoQuery.executeTakeFirst();
if (video) {
const videoMatchQuery = db
.selectFrom("VideoMatch")
.select([
"VideoMatch.id",
"VideoMatch.mode",
"VideoMatch.stageId",
"VideoMatch.startsAt",
])
.leftJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
)
.leftJoin("User", "VideoMatchPlayer.playerUserId", "User.id")
.select(({ fn, eb }) => [
fn
.agg("json_group_array", ["VideoMatchPlayer.weaponSplId"])
.$castTo<MainWeaponId[]>()
.as("weapons"),
fn
.agg("json_group_array", ["VideoMatchPlayer.playerName"])
.filterWhere("VideoMatchPlayer.playerName", "is not", null)
.$castTo<string[]>()
.as("playerNames"),
fn
.agg("json_group_array", [commonUserJsonObject(eb)])
.filterWhere("User.username", "is not", null)
.$castTo<CommonUser[]>()
.as("players"),
])
.where("VideoMatch.videoId", "=", id)
.groupBy("VideoMatch.id")
.orderBy("VideoMatch.startsAt", "asc")
.orderBy("VideoMatchPlayer.player", "asc");
const matches = await videoMatchQuery.execute();
return {
...video,
pov: resolvePov(matches),
matches: R.map(matches, R.omit(["players", "playerNames"])),
};
}
return null;
}
function resolvePov(
matches: Array<{ playerNames: string[]; players: CommonUser[] }>,
): Vod["pov"] {
for (const match of matches) {
if (match.playerNames.length > 0) {
return match.playerNames[0];
}
if (match.players.length > 0) {
return match.players[0];
}
}
return;
}
export async function update(
args: VideoBeingAdded & {
submitterUserId: number;
isValidated: boolean;
id: number;
},
) {
return insert(args);
}
export async function insert(
args: VideoBeingAdded & {
submitterUserId: number;
isValidated: boolean;
id?: number;
},
) {
const youtubeId = extractYoutubeIdFromVideoUrl(args.youtubeUrl);
invariant(youtubeId, "Invalid YouTube URL");
return db.transaction().execute(async (trx) => {
let videoId: number;
const video = {
title: args.title,
type: args.type,
youtubeDate: dayMonthYearToDatabaseTimestamp(args.date),
eventId: args.eventId ?? null,
youtubeId,
submitterUserId: args.submitterUserId,
validatedAt: args.isValidated
? dateToDatabaseTimestamp(new Date())
: null,
};
if (args.id) {
await trx
.deleteFrom("VideoMatch")
.where("videoId", "=", args.id)
.execute();
await trx
.updateTable("UnvalidatedVideo")
.set(video)
.where("id", "=", args.id)
.execute();
videoId = args.id;
} else {
const result = await trx
.insertInto("UnvalidatedVideo")
.values(video)
.returning("UnvalidatedVideo.id")
.executeTakeFirstOrThrow();
videoId = result.id;
}
for (const match of args.matches) {
const videoMatchResult = await trx
.insertInto("VideoMatch")
.values({
videoId: videoId,
startsAt: hoursMinutesSecondsStringToSeconds(match.startsAt),
stageId: match.stageId,
mode: match.mode,
})
.returning("VideoMatch.id")
.executeTakeFirstOrThrow();
const matchId = videoMatchResult.id;
for (const [i, weaponSplId] of match.weapons.entries()) {
await trx
.insertInto("VideoMatchPlayer")
.values({
videoMatchId: matchId,
playerUserId: args.pov?.type === "USER" ? args.pov.userId : null,
playerName: args.pov?.type === "NAME" ? args.pov.name : null,
weaponSplId,
player: i + 1,
})
.executeTakeFirstOrThrow();
}
}
return { ...video, id: videoId };
});
}
export function deleteById(id: number) {
return db.deleteFrom("UnvalidatedVideo").where("id", "=", id).execute();
}