diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index eb8aae70c..a4e7b5d6e 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -45,7 +45,7 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { createVod } from "~/features/vods/queries/createVod.server"; +import * as VodRepository from "~/features/vods/VodRepository.server"; import { secondsToHoursMinutesSecondString, youtubeIdToYoutubeUrl, @@ -1722,8 +1722,8 @@ function otherTeams() { } } -function realVideo() { - createVod({ +async function realVideo() { + await VodRepository.createVod({ type: "TOURNAMENT", youtubeUrl: youtubeIdToYoutubeUrl("M4aV-BQWlVg"), date: { day: 2, month: 2, year: 2023 }, @@ -1776,8 +1776,8 @@ function realVideo() { }); } -function realVideoCast() { - createVod({ +async function realVideoCast() { + await VodRepository.createVod({ type: "CAST", youtubeUrl: youtubeIdToYoutubeUrl("M4aV-BQWlVg"), date: { day: 2, month: 2, year: 2023 }, diff --git a/app/features/user-page/loaders/u.$identifier.vods.server.ts b/app/features/user-page/loaders/u.$identifier.vods.server.ts index 488de8cab..077fc5cfc 100644 --- a/app/features/user-page/loaders/u.$identifier.vods.server.ts +++ b/app/features/user-page/loaders/u.$identifier.vods.server.ts @@ -1,6 +1,6 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { findVods } from "~/features/vods/queries/findVods.server"; +import * as VodRepository from "~/features/vods/VodRepository.server"; import { notFoundIfFalsy } from "~/utils/remix.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { @@ -10,6 +10,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { return { // TODO: add pagination instead of not showing oldest vods at all - vods: findVods({ userId, limit: 100 }), + vods: await VodRepository.findVodsByUserId(userId), }; }; diff --git a/app/features/vods/VodRepository.server.ts b/app/features/vods/VodRepository.server.ts index c3f994df2..41faf4a71 100644 --- a/app/features/vods/VodRepository.server.ts +++ b/app/features/vods/VodRepository.server.ts @@ -1,5 +1,273 @@ +import { expressionBuilder, sql } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/sqlite"; +import * as R from "remeda"; import { db } from "~/db/sql"; +import type { DB, 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 { VODS_PAGE_BATCH_SIZE } from "./vods-constants"; +import type { VideoBeingAdded, Vod } from "./vods-types"; +import { + extractYoutubeIdFromVideoUrl, + hoursMinutesSecondsStringToSeconds, +} from "./vods-utils"; export function deleteById(id: number) { return db.deleteFrom("UnvalidatedVideo").where("id", "=", id).execute(); } + +export async function findVodsByUserId( + 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", "Video.id", "VideoMatch.videoId") + .leftJoin( + "VideoMatchPlayer", + "VideoMatch.id", + "VideoMatchPlayer.videoMatchId", + ) + .leftJoin("User", "VideoMatchPlayer.playerUserId", "User.id") + .selectAll("Video") + .select(({ fn, ref }) => [ + sql< + Array + >`json_group_array(distinct ${ref("VideoMatchPlayer.weaponSplId")})` + .$castTo() + .as("weapons"), + fn + .agg("json_group_array", ["VideoMatchPlayer.playerName"]) + .$castTo() + .as("playerNames"), + selectPlayers(), + ]); + 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(selectPlayers()) + .select(({ fn }) => [ + fn + .agg("json_group_array", ["VideoMatchPlayer.weaponSplId"]) + .$castTo() + .as("weapons"), + fn + .agg("json_group_array", ["VideoMatchPlayer.playerName"]) + .as("playerNames"), + ]) + .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: any): 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 updateVodByReplacing( + args: VideoBeingAdded & { + submitterUserId: number; + isValidated: boolean; + id: number; + }, +) { + return createVod(args); +} + +export async function createVod( + 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 }; + }); +} + +function selectPlayers() { + const eb = expressionBuilder(); + + return jsonArrayFrom( + eb + .selectFrom("User") + .select([ + "User.username", + "User.discordId", + "User.discordAvatar", + "User.customUrl", + ]), + ).as("players"); +} diff --git a/app/features/vods/actions/vods.$id.server.ts b/app/features/vods/actions/vods.$id.server.ts index 5021f3f98..1a1afad8e 100644 --- a/app/features/vods/actions/vods.$id.server.ts +++ b/app/features/vods/actions/vods.$id.server.ts @@ -2,14 +2,15 @@ import { type ActionFunctionArgs, redirect } from "@remix-run/node"; import { requireUser } from "~/features/auth/core/user.server"; import { badRequestIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server"; import { userVodsPage } from "~/utils/urls"; -import { findVodById } from "../queries/findVodById.server"; import * as VodRepository from "../VodRepository.server"; import { canEditVideo } from "../vods-utils"; export const action = async ({ request, params }: ActionFunctionArgs) => { const user = await requireUser(request); - const vod = badRequestIfFalsy(findVodById(Number(params.id))); + const vod = badRequestIfFalsy( + await VodRepository.findVodById(Number(params.id)), + ); unauthorizedIfFalsy( canEditVideo({ diff --git a/app/features/vods/actions/vods.new.server.ts b/app/features/vods/actions/vods.new.server.ts index bee504cd7..0fed8cb60 100644 --- a/app/features/vods/actions/vods.new.server.ts +++ b/app/features/vods/actions/vods.new.server.ts @@ -4,8 +4,7 @@ import { requireUser } from "~/features/auth/core/user.server"; import { requireRole } from "~/modules/permissions/guards.server"; import { notFoundIfFalsy, parseRequestPayload } from "~/utils/remix.server"; import { vodVideoPage } from "~/utils/urls"; -import { createVod, updateVodByReplacing } from "../queries/createVod.server"; -import { findVodById } from "../queries/findVodById.server"; +import * as VodRepository from "../VodRepository.server"; import { videoInputSchema } from "../vods-schemas"; import { canEditVideo } from "../vods-utils"; @@ -20,7 +19,9 @@ export const action: ActionFunction = async ({ request }) => { let video: Tables["Video"]; if (data.vodToEditId) { - const vod = notFoundIfFalsy(findVodById(data.vodToEditId)); + const vod = notFoundIfFalsy( + await VodRepository.findVodById(data.vodToEditId), + ); if ( !canEditVideo({ @@ -32,14 +33,14 @@ export const action: ActionFunction = async ({ request }) => { throw new Response("no permissions to edit this vod", { status: 401 }); } - video = updateVodByReplacing({ + video = await VodRepository.updateVodByReplacing({ ...data.video, submitterUserId: user.id, isValidated: true, id: data.vodToEditId, }); } else { - video = createVod({ + video = await VodRepository.createVod({ ...data.video, submitterUserId: user.id, isValidated: true, diff --git a/app/features/vods/loaders/vods.$id.server.ts b/app/features/vods/loaders/vods.$id.server.ts index 8f49c69c1..8dc930bc7 100644 --- a/app/features/vods/loaders/vods.$id.server.ts +++ b/app/features/vods/loaders/vods.$id.server.ts @@ -1,9 +1,11 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { notFoundIfFalsy } from "~/utils/remix.server"; -import { findVodById } from "../queries/findVodById.server"; +import * as VodRepository from "../VodRepository.server"; -export const loader = ({ params }: LoaderFunctionArgs) => { - const vod = notFoundIfFalsy(findVodById(Number(params.id))); +export const loader = async ({ params }: LoaderFunctionArgs) => { + const vod = notFoundIfFalsy( + await VodRepository.findVodById(Number(params.id)), + ); return { vod }; }; diff --git a/app/features/vods/loaders/vods.new.server.ts b/app/features/vods/loaders/vods.new.server.ts index 987165c32..90eb9f3f6 100644 --- a/app/features/vods/loaders/vods.new.server.ts +++ b/app/features/vods/loaders/vods.new.server.ts @@ -3,7 +3,7 @@ import { z } from "zod/v4"; import { requireUser } from "~/features/auth/core/user.server"; import { notFoundIfFalsy } from "~/utils/remix.server"; import { actualNumber, id } from "~/utils/zod"; -import { findVodById } from "../queries/findVodById.server"; +import * as VodRepository from "../VodRepository.server"; import { canEditVideo, vodToVideoBeingAdded } from "../vods-utils"; const newVodLoaderParamsSchema = z.object({ @@ -22,7 +22,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return { vodToEdit: null }; } - const vod = notFoundIfFalsy(findVodById(params.data.vod)); + const vod = notFoundIfFalsy(await VodRepository.findVodById(params.data.vod)); const vodToEdit = vodToVideoBeingAdded(vod); if ( diff --git a/app/features/vods/loaders/vods.server.ts b/app/features/vods/loaders/vods.server.ts index a879a09b3..f695fd7b1 100644 --- a/app/features/vods/loaders/vods.server.ts +++ b/app/features/vods/loaders/vods.server.ts @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import { findVods } from "../queries/findVods.server"; +import * as VodRepository from "../VodRepository.server"; import { VODS_PAGE_BATCH_SIZE } from "../vods-constants"; export const loader = async ({ request }: LoaderFunctionArgs) => { @@ -7,7 +7,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const limit = Number(url.searchParams.get("limit") ?? VODS_PAGE_BATCH_SIZE); - const vods = findVods({ + const vods = await VodRepository.findVods({ ...Object.fromEntries( Array.from(url.searchParams.entries()).filter(([, value]) => value), ), diff --git a/app/features/vods/queries/createVod.server.ts b/app/features/vods/queries/createVod.server.ts deleted file mode 100644 index d2b8545fe..000000000 --- a/app/features/vods/queries/createVod.server.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; -import { - dateToDatabaseTimestamp, - dayMonthYearToDatabaseTimestamp, -} from "~/utils/dates"; -import invariant from "~/utils/invariant"; -import type { VideoBeingAdded } from "../vods-types"; -import { - extractYoutubeIdFromVideoUrl, - hoursMinutesSecondsStringToSeconds, -} from "../vods-utils"; - -const createVideoStm = sql.prepare(/* sql */ ` - insert into "UnvalidatedVideo" - ("id", "title", "type", "youtubeDate", "eventId", "youtubeId", "submitterUserId", "validatedAt") - values - (@id, @title, @type, @youtubeDate, @eventId, @youtubeId, @submitterUserId, @validatedAt) - returning * -`); - -const deleteVideoStm = sql.prepare(/* sql */ ` - delete from "UnvalidatedVideo" - where "id" = @id -`); - -const createVideoMatchStm = sql.prepare(/* sql */ ` - insert into "VideoMatch" - ("videoId", "startsAt", "stageId", "mode") - values - (@videoId, @startsAt, @stageId, @mode) - returning * -`); - -const createVideoMatchPlayerStm = sql.prepare(/* sql */ ` - insert into "VideoMatchPlayer" - ("videoMatchId", "playerUserId", "playerName", "weaponSplId", "player") - values - (@videoMatchId, @playerUserId, @playerName, @weaponSplId, @player) -`); - -export const createVod = sql.transaction( - ( - args: VideoBeingAdded & { - submitterUserId: number; - isValidated: boolean; - id?: number; - }, - ) => { - const youtubeId = extractYoutubeIdFromVideoUrl(args.youtubeUrl); - invariant(youtubeId, "Invalid YouTube URL"); - - const video = createVideoStm.get({ - id: args.id ?? null, - 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, - }) as Tables["Video"]; - - for (const match of args.matches) { - const videoMatch = createVideoMatchStm.get({ - videoId: video.id, - startsAt: hoursMinutesSecondsStringToSeconds(match.startsAt), - stageId: match.stageId, - mode: match.mode, - }) as any; - - for (const [i, weaponSplId] of match.weapons.entries()) { - createVideoMatchPlayerStm.run({ - videoMatchId: videoMatch.id, - playerUserId: args.pov?.type === "USER" ? args.pov.userId : null, - playerName: args.pov?.type === "NAME" ? args.pov.name : null, - weaponSplId, - player: i + 1, - }); - } - } - - return video; - }, -); - -export const updateVodByReplacing = sql.transaction( - ( - args: VideoBeingAdded & { - submitterUserId: number; - isValidated: boolean; - id: number; - }, - ) => { - deleteVideoStm.run({ id: args.id }); - const video = createVod(args); - - return video; - }, -); diff --git a/app/features/vods/queries/findVodById.server.ts b/app/features/vods/queries/findVodById.server.ts deleted file mode 100644 index 36679f5e9..000000000 --- a/app/features/vods/queries/findVodById.server.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { sql } from "~/db/sql"; -import { parseDBArray } from "~/utils/sql"; -import type { Vod } from "../vods-types"; - -const videoStm = sql.prepare(/* sql */ ` - select - v."id", - v."title", - v."youtubeDate", - v."youtubeId", - v."type", - v."submitterUserId" - from "Video" v - where v."id" = @id -`); - -const videoMatchesStm = sql.prepare(/* sql */ ` - select - vm."id", - vm."mode", - vm."stageId", - vm."startsAt", - json_group_array("vp"."weaponSplId") as "weapons", - json_group_array("vp"."playerName") as "playerNames", - json_group_array( - json_object( - 'username', - "u"."username", - 'discordId', - "u"."discordId", - 'discordAvatar', - "u"."discordAvatar", - 'customUrl', - "u"."customUrl", - 'id', - "u"."id" - ) - ) as "players" - from "VideoMatch" vm - left join "VideoMatchPlayer" vp on vm."id" = vp."videoMatchId" - left join "User" u on vp."playerUserId" = u."id" - where vm."videoId" = @id - group by vm."id" - order by vm."startsAt" asc, vp."player" asc -`); - -export function findVodById(id: Vod["id"]): Vod | null { - const video = videoStm.get({ id }) as any; - if (!video) return null; - - const matches = videoMatchesStm.all({ id }) as any[]; - - return { - ...video, - pov: resolvePov(matches), - matches: matches.map(({ players: _1, playerNames: _2, ...match }) => { - return { - ...match, - weapons: parseDBArray(match.weapons), - }; - }), - }; -} - -function resolvePov(matches: any): Vod["pov"] { - for (const match of matches) { - if (parseDBArray(match.playerNames).length > 0) { - return parseDBArray(match.playerNames)[0]; - } - - if (parseDBArray(match.players).length > 0) { - return parseDBArray(match.players)[0]; - } - } - - return; -} diff --git a/app/features/vods/queries/findVods.server.ts b/app/features/vods/queries/findVods.server.ts deleted file mode 100644 index bf83d1d52..000000000 --- a/app/features/vods/queries/findVods.server.ts +++ /dev/null @@ -1,99 +0,0 @@ -import * as R from "remeda"; -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; -import type { - MainWeaponId, - ModeShort, - StageId, -} from "~/modules/in-game-lists/types"; -import { parseDBArray, parseDBJsonArray } from "~/utils/sql"; -import { weaponIdToArrayWithAlts } from "../../../modules/in-game-lists/weapon-ids"; -import { VODS_PAGE_BATCH_SIZE } from "../vods-constants"; -import type { ListVod } from "../vods-types"; - -const query = (byUser?: true) => /* sql */ ` - select - v."id", - v."title", - v."youtubeId", - v."type", - json_group_array("vp"."weaponSplId") as "weapons", - json_group_array("vp"."playerName") as "playerNames", - json_group_array( - json_object( - 'username', - "u"."username", - 'discordId', - "u"."discordId", - 'discordAvatar', - "u"."discordAvatar", - 'customUrl', - "u"."customUrl" - ) - ) as "players" - from "Video" v - left join "VideoMatch" vm on v."id" = vm."videoId" - left join "VideoMatchPlayer" vp on vm."id" = vp."videoMatchId" - left join "User" u on vp."playerUserId" = u."id" - where ${ - byUser - ? /* sql */ `u."id" = @userId` - : /* sql */ ` - v."type" = coalesce(@type, v."type") - and vm."mode" = coalesce(@mode, vm."mode") - and vm."stageId" = coalesce(@stageId, vm."stageId")` - } - group by v."id" - order by v."youtubeDate" desc -`; - -const stm = sql.prepare(query()); -const stmByUser = sql.prepare(query(true)); - -export 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; -}): Array { - const stmToUse = userId ? stmByUser : stm; - - const vods = stmToUse.all({ - mode: mode ?? null, - stageId: stageId ?? null, - type: type ?? null, - userId: userId ?? null, - }) as any[]; - - const weaponIdsToFilterBy = weapon - ? weaponIdToArrayWithAlts(Number(weapon) as MainWeaponId) // TODO: fix on caller side - : []; - - return vods - .filter((vod) => { - if (weaponIdsToFilterBy.length === 0) return true; - return parseDBArray(vod.weapons).some((weaponId: any) => - weaponIdsToFilterBy.includes(weaponId), - ); - }) - .map(({ playerNames: playerNamesRaw, players: playersRaw, ...vod }) => { - const playerNames = parseDBArray(playerNamesRaw); - const players = parseDBJsonArray(playersRaw); - - return { - ...vod, - weapons: R.unique(parseDBArray(vod.weapons)), - pov: playerNames[0] ?? players[0], - }; - }) - .slice(0, limit); -}