Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow uploading replayed matches #97

Merged
merged 1 commit into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading