Skip to content

Commit

Permalink
Allow uploading replayed matches (#95)
Browse files Browse the repository at this point in the history
Co-authored-by: Evan Strat <[email protected]>
  • Loading branch information
evan10s and evan10s authored Feb 17, 2024
1 parent 3a583ad commit fdaaa61
Show file tree
Hide file tree
Showing 14 changed files with 108 additions and 24 deletions.
10 changes: 10 additions & 0 deletions client/src/components/help/MissingMatchVideosHelp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@
<li>
Ensure your video files are named correctly (review <strong>How to name match video files</strong>).
</li>
<li>
Check the Replay metadata setting
<span v-if="matchStore.selectedMatchKey">(current value: {{ matchStore.isReplay ? "on" : "off" }})</span>.
When the Replay setting is on, only video files for the replay of the match are returned. Replayed videos
should generally be named like <code>Qualification 1 Replay.ext</code>. For full details, see
<strong>How to name match video files</strong>.
</li>
</ol>
</VExpansionPanelText>
</VExpansionPanel>
</template>
<script lang="ts" setup>
import { useMatchStore } from "@/stores/match";
const matchStore = useMatchStore();
</script>
10 changes: 9 additions & 1 deletion client/src/components/help/NameMatchVideoFilesHelp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@ videos/
<pre>$Label/Final #.mp4</pre>
<p><strong>Examples</strong>: Overhead/Playoff 10.mp4, Feed B/Final 1.mp4</p>
<br />
Be sure to set your playoff type in Settings so we know how to parse your playoff matches!
<p>Be sure to set your playoff type in Settings so we know how to parse your playoff matches!</p>
<br />
<strong>Replayed matches:</strong>
<p>
For a replayed match, insert "Replay" (case-insensitive) between the match title and the file extension. For
instance, <code>unlabeled/Qualification 1.mp4</code> would become
<code>unlabeled/Qualification 1 Replay.mp4</code>. <code>$LABEL/Playoff 1.mp4</code> would become
<code>$LABEL/Playoff 1 Replay.mp4</code>
</p>
</VExpansionPanelText>
</VExpansionPanel>
</template>
Expand Down
19 changes: 19 additions & 0 deletions client/src/components/matches/MatchMetadata.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<div v-if="matchStore.selectedMatchKey">
<VSwitch v-model="matchStore.isReplay"
color="primary"
label="Replay"
inset
/>
</div>
</template>
<script lang="ts" setup>
import { useMatchStore } from "@/stores/match";
const matchStore = useMatchStore();
</script>
<style scoped>
:deep(.v-label) {
opacity: 1;
}
</style>
13 changes: 6 additions & 7 deletions client/src/components/matches/MatchVideosUploader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,21 @@
<VCard class="ml-4 mt-2 mb-2">
<VCardTitle>Videos to upload</VCardTitle>
<VCardText>
<VProgressCircular v-if="matchStore.matchVideosLoading"
indeterminate
class="mb-2"
/>
<LoadingSpinner v-if="matchStore.matchVideosLoading" class="mt-2 mb-4" />
<div v-else-if="matchStore.selectedMatchKey">
<VChip v-if="matchStore.isReplay" color="info">Replay</VChip>
<VList v-if="matchStore.matchVideos.length">
<MatchVideoListItem v-for="video in matchStore.matchVideos"
:key="video.path"
:video="video"
/>
</VList>
<VAlert v-else
class="mb-4"
class="mt-2 mb-2"
color="warning"
variant="tonal"
>
No video files found for this match
No video files found
</VAlert>
<VBtn prepend-icon="mdi-refresh"
variant="outlined"
Expand Down Expand Up @@ -70,12 +68,13 @@ import SandboxModeAlert from "@/components/alerts/SandboxModeAlert.vue";
import PrivateUploads from "@/components/alerts/PrivateUploads.vue";
import QueueAllVideosBtn from "@/components/matches/QueueAllVideosBtn.vue";
import { computed } from "vue";
import LoadingSpinner from "@/components/util/LoadingSpinner.vue";
const matchStore = useMatchStore();
const settingsStore = useSettingsStore();
const showQueueAllBtn = computed(() => {
return !matchStore.allMatchVideosUploaded;
return !matchStore.matchVideosLoading && !matchStore.allMatchVideosUploaded;
});
const queueAllBtnColor = computed(() => {
Expand Down
19 changes: 19 additions & 0 deletions client/src/components/util/LoadingSpinner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<div class="text-center">
<VProgressCircular indeterminate
:color="color"
:width="width"
/>
</div>
</template>
<script lang="ts" setup>
interface IProps {
color?: string;
width?: number;
}
const props = withDefaults(defineProps<IProps>(), {
color: "primary",
width: 3,
});
</script>
11 changes: 9 additions & 2 deletions client/src/stores/match.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import { MatchVideoInfo } from "@/types/MatchVideoInfo";
import { useSettingsStore } from "@/stores/settings";
import { useWorkerStore } from "@/stores/worker";
Expand All @@ -11,11 +11,13 @@ export const useMatchStore = defineStore("match", () => {
const settingsStore = useSettingsStore();
const workerStore = useWorkerStore();
const selectedMatchKey = ref<string | null>(null);
const isReplay = ref(false);

const uploadInProgress = ref(false);

async function selectMatch(matchKey: string) {
selectedMatchKey.value = matchKey;
isReplay.value = false;
await getMatchVideos();
await getSuggestedDescription();
}
Expand Down Expand Up @@ -57,7 +59,7 @@ export const useMatchStore = defineStore("match", () => {
matchVideosLoading.value = true;
matchVideoError.value = "";

const result = await fetch(`/api/v1/matches/${selectedMatchKey.value}/videos/recommend`);
const result = await fetch(`/api/v1/matches/${selectedMatchKey.value}/videos/recommend?isReplay=${isReplay.value}`);

if (!result.ok) {
const message = `Unable to retrieve video file suggestions for ${selectedMatchKey.value}`;
Expand Down Expand Up @@ -224,6 +226,10 @@ export const useMatchStore = defineStore("match", () => {
return !!job.linkedOnTheBlueAlliance && !!job.addedToYouTubePlaylist;
}

watch(isReplay, async (value, oldValue, onCleanup) => {
await getMatchVideos();
});

return {
advanceMatch,
allMatchVideosQueued,
Expand All @@ -234,6 +240,7 @@ export const useMatchStore = defineStore("match", () => {
descriptionLoading,
getMatchVideos,
getSuggestedDescription,
isReplay,
matchVideoError,
matchVideos,
matchVideosLoading,
Expand Down
5 changes: 4 additions & 1 deletion client/src/views/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
<h2
class="mt-2 mb-2"
>
Video description
Video metadata
</h2>
<MatchMetadata />
<h3 class="mb-2">Description</h3>
<MatchDescription />
</VCol>
<VCol cols="12" :md="videosMdColWidth">
Expand Down Expand Up @@ -80,6 +82,7 @@ import UploadErrors from "@/components/help/UploadErrors.vue";
import MissingPlaylistMapping from "@/components/help/MissingPlaylistMapping.vue";
import JobsList from "@/components/jobs/JobsList.vue";
import {useWorkerStore} from "@/stores/worker";
import MatchMetadata from "@/components/matches/MatchMetadata.vue";
const error = ref("");
Expand Down
2 changes: 2 additions & 0 deletions server/env/test.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE

WORKER_WEB_SERVER_URL=http://web:8080

POSTGRES_DB=match_uploader
POSTGRES_USER=match_uploader
POSTGRES_PASSWORD=
Expand Down
1 change: 1 addition & 0 deletions server/spec/tests/repos/MatchesServices.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("MatchesService", () => {

const files = await getLocalVideoFilesForMatch(
MatchKey.fromString("2023gadal_qm16", PlayoffsType.DoubleElimination),
false,
);
expect(files).toEqual([]);
mockFs.restore();
Expand Down
13 changes: 8 additions & 5 deletions server/src/models/Match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { CompLevel } from "@src/models/CompLevel";

export class Match {
key: MatchKey;
isReplay: boolean;

constructor(key: MatchKey) {
constructor(key: MatchKey, isReplay: boolean = false) {
this.key = key;
this.isReplay = isReplay;
}

/**
Expand All @@ -17,24 +19,25 @@ export class Match {
*/
private generateMatchName(includeMatch: boolean, includeDoubleElimRound: boolean): string {
const match = includeMatch ? "Match " : "";
const replay = this.isReplay ? " Replay" : "";

const fullCompLevel = this.key.compLevel;

const playoffsRound = this.key.playoffsRound;
if (playoffsRound) {
const round = includeDoubleElimRound ? ` (R${playoffsRound})` : "";
return `Playoff ${match}${this.key.setNumber}${round}`;
return `Playoff ${match}${this.key.setNumber}${round}${replay}`;
}

if (this.key.setNumber) {
if (fullCompLevel === CompLevel.Final) {
return `${fullCompLevel} ${match}${this.key.matchNumber}`;
return `${fullCompLevel} ${match}${this.key.matchNumber}${replay}`;
}

return `${fullCompLevel} ${this.key.setNumber} ${match}${this.key.matchNumber}`;
return `${fullCompLevel} ${this.key.setNumber} ${match}${this.key.matchNumber}${replay}`;
}

return `${fullCompLevel} ${match}${this.key.matchNumber}`;
return `${fullCompLevel} ${match}${this.key.matchNumber}${replay}`;
}

get matchName(): string {
Expand Down
4 changes: 4 additions & 0 deletions server/src/repos/FileStorageRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@ const DEFAULT_ENCODING = "utf-8";
* @param dir What to set as the current working directory for glob lookup
* @param pattern Glob pattern to match files against
* @param depth Corresponds to fast-glob's `deep` option, see fast-glob docs for more information
* @param caseSensitiveMatch Corresponds to fast-glob's `caseSensitiveMatch` option, see fast-glob docs for more
* information
*/
export async function getFilesMatchingPattern(
dir: PathLike,
pattern: string,
depth: number = Infinity,
caseSensitiveMatch: boolean = true,
): Promise<string[]> {
logger.info(`getFilesMatchingPattern: dir: ${dir}, pattern: ${pattern}, deep: ${depth}`);
return await fastGlob.async(pattern, {
cwd: dir.toString(), // cwd is relative to the directory the server is running out of
onlyFiles: true,
deep: depth,
caseSensitiveMatch,
});
}

Expand Down
7 changes: 4 additions & 3 deletions server/src/routes/matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getLocalVideoFilesForMatch,
getMatchList,
} from "@src/services/MatchesService";
import { matchedData, param, validationResult } from "express-validator";
import { matchedData, param, query, validationResult } from "express-validator";
import MatchKey from "@src/models/MatchKey";
import { type MatchVideoInfo } from "@src/models/MatchVideoInfo";
import { Match } from "@src/models/Match";
Expand Down Expand Up @@ -48,6 +48,7 @@ matchesRouter.get(
"matchKey",
"Match key is required and must pass a format test. (See MatchKey class for regex.)",
).isString().matches(MatchKey.matchKeyRegex),
query("isReplay", "isReplay must be a boolean").isBoolean().toBoolean(),
recommendVideoFiles,
);

Expand All @@ -62,14 +63,14 @@ async function recommendVideoFiles(req: IReq, res: IRes): Promise<void> {
return;
}

const { matchKey } = matchedData(req);
const { matchKey, isReplay } = matchedData(req);

const { playoffsType: playoffsTypeRaw } = await getSettings();
const playoffsType = playoffsTypeRaw as PlayoffsType;

const matchKeyObject = MatchKey.fromString(matchKey as string, playoffsType);

const recommendedVideoFiles = await getLocalVideoFilesForMatch(matchKeyObject);
const recommendedVideoFiles = await getLocalVideoFilesForMatch(matchKeyObject, isReplay as boolean);
res.json({
ok: true,
recommendedVideoFiles: recommendedVideoFiles.map((recommendation: MatchVideoInfo) => recommendation.toJson()),
Expand Down
15 changes: 11 additions & 4 deletions server/src/services/MatchesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,24 @@ import { getFrcApiMatchNumber } from "@src/models/frcEvents/frcScoredMatch";
import { toFrcEventsUrlTournamentLevel } from "@src/models/CompLevel";
import Mustache from "mustache";

export async function getLocalVideoFilesForMatch(matchKey: MatchKey): Promise<MatchVideoInfo[]> {
export async function getLocalVideoFilesForMatch(matchKey: MatchKey, isReplay: boolean): Promise<MatchVideoInfo[]> {
const { eventName, videoSearchDirectory } = await getSettings();
const match = new Match(matchKey);
const match = new Match(matchKey, isReplay);
const videoFileMatchingName = capitalizeFirstLetter(match.videoFileMatchingName);
const matchTitleName = capitalizeFirstLetter(match.verboseMatchName);

const files = await getFilesMatchingPattern(videoSearchDirectory, `**/${videoFileMatchingName}.*`, 2);
const files = await getFilesMatchingPattern(
videoSearchDirectory,
`**/${videoFileMatchingName}.*`,
2,
false,
);
const uploadedFiles = await getFilesMatchingPattern(
videoSearchDirectory,
`**/uploaded/${videoFileMatchingName}.*`,
3);
3,
false,
);

files.push(...uploadedFiles);
return files
Expand Down
3 changes: 2 additions & 1 deletion server/src/tasks/uploadVideo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ async function moveToUploadedDirectory(
const toPath = path.join(videosDirectory, splitPath[0], "uploaded", splitPath[1]);

if (dryRun) {
logger.info(`[Sandbox mode] Woul dhave moved video file ${fromPath} to uploaded directory ${toPath}`);
logger.info(`[Sandbox mode] Would have moved video file ${fromPath} to uploaded directory ${toPath}`);
return;
}

return await fs.move(fromPath, toPath);
Expand Down

0 comments on commit fdaaa61

Please sign in to comment.