From 63f7741c7abe3653a0a45511b4040919781f42eb Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 2 Sep 2025 19:45:30 +0300 Subject: [PATCH] Revert "Replace the old sql queries with kysely queries for the VODs feature (#2423)" This reverts commit 55c580f53a3b2cf615fb56e8ad3d7d0fb37acedc. --- app/db/seed/index.ts | 10 +- .../loaders/u.$identifier.vods.server.ts | 4 +- app/features/vods/VodRepository.server.ts | 268 ------------------ app/features/vods/actions/vods.$id.server.ts | 5 +- app/features/vods/actions/vods.new.server.ts | 11 +- app/features/vods/loaders/vods.$id.server.ts | 8 +- app/features/vods/loaders/vods.new.server.ts | 4 +- app/features/vods/loaders/vods.server.ts | 4 +- app/features/vods/queries/createVod.server.ts | 102 +++++++ .../vods/queries/findVodById.server.ts | 77 +++++ app/features/vods/queries/findVods.server.ts | 99 +++++++ 11 files changed, 299 insertions(+), 293 deletions(-) create mode 100644 app/features/vods/queries/createVod.server.ts create mode 100644 app/features/vods/queries/findVodById.server.ts create mode 100644 app/features/vods/queries/findVods.server.ts diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index a4e7b5d6e..eb8aae70c 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 * as VodRepository from "~/features/vods/VodRepository.server"; +import { createVod } from "~/features/vods/queries/createVod.server"; import { secondsToHoursMinutesSecondString, youtubeIdToYoutubeUrl, @@ -1722,8 +1722,8 @@ function otherTeams() { } } -async function realVideo() { - await VodRepository.createVod({ +function realVideo() { + createVod({ type: "TOURNAMENT", youtubeUrl: youtubeIdToYoutubeUrl("M4aV-BQWlVg"), date: { day: 2, month: 2, year: 2023 }, @@ -1776,8 +1776,8 @@ async function realVideo() { }); } -async function realVideoCast() { - await VodRepository.createVod({ +function realVideoCast() { + 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 077fc5cfc..488de8cab 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 * as VodRepository from "~/features/vods/VodRepository.server"; +import { findVods } from "~/features/vods/queries/findVods.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: await VodRepository.findVodsByUserId(userId), + vods: findVods({ userId, limit: 100 }), }; }; diff --git a/app/features/vods/VodRepository.server.ts b/app/features/vods/VodRepository.server.ts index 41faf4a71..c3f994df2 100644 --- a/app/features/vods/VodRepository.server.ts +++ b/app/features/vods/VodRepository.server.ts @@ -1,273 +1,5 @@ -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 1a1afad8e..5021f3f98 100644 --- a/app/features/vods/actions/vods.$id.server.ts +++ b/app/features/vods/actions/vods.$id.server.ts @@ -2,15 +2,14 @@ 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( - await VodRepository.findVodById(Number(params.id)), - ); + const vod = badRequestIfFalsy(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 0fed8cb60..bee504cd7 100644 --- a/app/features/vods/actions/vods.new.server.ts +++ b/app/features/vods/actions/vods.new.server.ts @@ -4,7 +4,8 @@ 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 * as VodRepository from "../VodRepository.server"; +import { createVod, updateVodByReplacing } from "../queries/createVod.server"; +import { findVodById } from "../queries/findVodById.server"; import { videoInputSchema } from "../vods-schemas"; import { canEditVideo } from "../vods-utils"; @@ -19,9 +20,7 @@ export const action: ActionFunction = async ({ request }) => { let video: Tables["Video"]; if (data.vodToEditId) { - const vod = notFoundIfFalsy( - await VodRepository.findVodById(data.vodToEditId), - ); + const vod = notFoundIfFalsy(findVodById(data.vodToEditId)); if ( !canEditVideo({ @@ -33,14 +32,14 @@ export const action: ActionFunction = async ({ request }) => { throw new Response("no permissions to edit this vod", { status: 401 }); } - video = await VodRepository.updateVodByReplacing({ + video = updateVodByReplacing({ ...data.video, submitterUserId: user.id, isValidated: true, id: data.vodToEditId, }); } else { - video = await VodRepository.createVod({ + video = 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 8dc930bc7..8f49c69c1 100644 --- a/app/features/vods/loaders/vods.$id.server.ts +++ b/app/features/vods/loaders/vods.$id.server.ts @@ -1,11 +1,9 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { notFoundIfFalsy } from "~/utils/remix.server"; -import * as VodRepository from "../VodRepository.server"; +import { findVodById } from "../queries/findVodById.server"; -export const loader = async ({ params }: LoaderFunctionArgs) => { - const vod = notFoundIfFalsy( - await VodRepository.findVodById(Number(params.id)), - ); +export const loader = ({ params }: LoaderFunctionArgs) => { + const vod = notFoundIfFalsy(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 90eb9f3f6..987165c32 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 * as VodRepository from "../VodRepository.server"; +import { findVodById } from "../queries/findVodById.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(await VodRepository.findVodById(params.data.vod)); + const vod = notFoundIfFalsy(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 f695fd7b1..a879a09b3 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 * as VodRepository from "../VodRepository.server"; +import { findVods } from "../queries/findVods.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 = await VodRepository.findVods({ + const vods = 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 new file mode 100644 index 000000000..d2b8545fe --- /dev/null +++ b/app/features/vods/queries/createVod.server.ts @@ -0,0 +1,102 @@ +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 new file mode 100644 index 000000000..36679f5e9 --- /dev/null +++ b/app/features/vods/queries/findVodById.server.ts @@ -0,0 +1,77 @@ +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 new file mode 100644 index 000000000..bf83d1d52 --- /dev/null +++ b/app/features/vods/queries/findVods.server.ts @@ -0,0 +1,99 @@ +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); +}