Skip to content

Commit

Permalink
feat(uploader): Optionally link match videos on The Blue Alliance (#57)
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 Oct 16, 2023
1 parent dc9c496 commit b4073f8
Show file tree
Hide file tree
Showing 22 changed files with 330 additions and 37 deletions.
28 changes: 27 additions & 1 deletion client/src/components/matches/MatchVideoListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
color="error"
size="large"
/>
<VIcon v-else-if="video.uploaded && !matchStore.postUploadStepsSucceeded(video)"
icon="mdi-alert"
color="warning"
size="large"
/>
<VIcon v-else-if="video.uploaded"
icon="mdi-cloud-check-variant"
color="success"
Expand Down Expand Up @@ -82,7 +87,28 @@ const uploadStatus = computed(() => {
});
const subtitle = computed(() => {
return `${uploadStatus.value} | ${props.video.path}`;
let postUploadStatus = "";
let playlistStatus = "";
let tbaStatus = "";
if (props.video.postUploadSteps) {
const playlist = props.video.postUploadSteps.addToYouTubePlaylist;
const tba = props.video.postUploadSteps.linkOnTheBlueAlliance;
if (playlist && tba) {
postUploadStatus = "Post-upload steps completed | ";
} else {
if (!playlist) {
playlistStatus = "Add to YouTube playlist failed | ";
}
if (!tba) {
tbaStatus = "TBA link failed | ";
}
postUploadStatus = `${playlistStatus}${tbaStatus}`;
}
}
return `${uploadStatus.value} | ${postUploadStatus}${props.video.path}`;
});
</script>
<style scoped>
Expand Down
8 changes: 8 additions & 0 deletions client/src/components/matches/MatchVideosUploader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
An error occurred while fetching the video description for this match. You may want to confirm its accuracy
before uploading.
</VAlert>
<VAlert v-if="matchStore.allMatchVideosUploaded"
color="success"
variant="tonal"
icon="mdi-check-circle"
class="mt-2 mb-4"
>
All videos uploaded!
</VAlert>
<VBtn :color="settingsStore.settings?.sandboxModeEnabled ? 'warning' : 'success'"
size="large"
:prepend-icon="matchStore.uploadInProgress ? 'mdi-loading mdi-spin' : ''"
Expand Down
13 changes: 12 additions & 1 deletion client/src/stores/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export const useMatchStore = defineStore("match", () => {
"Content-Type": "application/json",
},
body: JSON.stringify({
matchKey: selectedMatchKey.value,
videoPath: video.path,
videoTitle: video.videoTitle,
label: video.videoLabel ?? "Unlabeled",
Expand All @@ -118,6 +119,7 @@ export const useMatchStore = defineStore("match", () => {
video.uploaded = true;
video.youTubeVideoId = result.videoId;
video.youTubeVideoUrl = `https://www.youtube.com/watch?v=${result.videoId}`;
video.postUploadSteps = result.postUploadSteps;
video.uploadError = "";
} else {
// Catches if the server returns parameter validation errors
Expand Down Expand Up @@ -183,9 +185,17 @@ export const useMatchStore = defineStore("match", () => {
}

const allowMatchUpload = computed(() => {
return uploadInProgress.value && matchVideos.value.length || !descriptionLoading.value || description.value;
return !uploadInProgress.value
&& matchVideos.value.length
&& !descriptionLoading.value
&& description.value
&& !allMatchVideosUploaded.value;
});

function postUploadStepsSucceeded (video: MatchVideoInfo): boolean {
return !!video.postUploadSteps?.addToYouTubePlaylist && !!video.postUploadSteps?.linkOnTheBlueAlliance;
}

return {
advanceMatch,
allMatchVideosUploaded,
Expand All @@ -199,6 +209,7 @@ export const useMatchStore = defineStore("match", () => {
matchVideos,
matchVideosLoading,
matches,
postUploadStepsSucceeded,
selectMatch,
selectedMatchKey,
uploadInProgress,
Expand Down
2 changes: 2 additions & 0 deletions client/src/types/IObfuscatedSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ export interface IObfuscatedSecrets {
googleAccessToken: boolean,
googleRefreshToken: boolean,
theBlueAllianceReadApiKey: boolean,
theBlueAllianceTrustedApiAuthId: boolean,
theBlueAllianceTrustedApiAuthSecret: boolean,
[key: string]: boolean,
}
1 change: 1 addition & 0 deletions client/src/types/ISettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface ISettings {
googleAuthStatus: string,
sandboxModeEnabled: boolean,
youTubeVideoPrivacy: string,
linkVideosOnTheBlueAlliance: boolean;
[key: string]: string | boolean,
}

Expand Down
4 changes: 4 additions & 0 deletions client/src/types/MatchVideoInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ export interface MatchVideoInfo {
uploadError: string | null;
youTubeVideoId: string | null;
youTubeVideoUrl: string | null;
postUploadSteps: {
addToYouTubePlaylist: boolean;
linkOnTheBlueAlliance: boolean;
} | null;
}
12 changes: 0 additions & 12 deletions client/src/types/MatchVideoUploadInfo.ts

This file was deleted.

67 changes: 65 additions & 2 deletions client/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,67 @@
/>

<h2 class="mt-4">The Blue Alliance (TBA)</h2>
<h3>Read API</h3>
<AutosavingTextInput :key="`theBlueAllianceReadApiKey-${dataRefreshKey}`"
:on-submit="submit"
initial-value=""
name="theBlueAllianceReadApiKey"
label="TBA read API key"
input-type="password"
setting-type="secret"
:help-text="theBlueAllianceReadApiKeyHelpText"
:help-text="tbaReadApiKeyHelpText"
/>

<h3 class="mt-2">Trusted (write) API</h3>
<p>
You can use TBA's trusted API to associate uploaded videos with matches on TBA. To use this feature,
you must request write tokens for your event on
<a href="https://www.thebluealliance.com/request/apiwrite/">
https://www.thebluealliance.com/request/apiwrite/
</a>
</p>

<p class="mt-4">Link match videos on TBA</p>
<AutosavingBtnSelectGroup :choices="['On', 'Off']"
:default-value="settingsStore.settings?.linkVideosOnTheBlueAlliance ? 'On' : 'Off'"
:loading="savingTbaLinkVideos"
@on-choice-selected="saveTbaLinkVideos"
/>

<VAlert v-if="!settingsStore.settings?.linkVideosOnTheBlueAlliance"
color="info"
variant="tonal"
class="mt-4 mb-4"
>
Before you can enter your TBA trusted API auth ID and secret, you need to enable this feature using the
toggle above.
</VAlert>

<AutosavingTextInput v-if="
settingsStore.settings?.linkVideosOnTheBlueAlliance"
:key="`theBlueAllianceTrustedApiAuthId-${dataRefreshKey}`"
:on-submit="submit"
initial-value=""
name="theBlueAllianceTrustedApiAuthId"
label="TBA trusted API auth ID"
input-type="password"
setting-type="secret"
:help-text="settingsStore.obfuscatedSecrets?.theBlueAllianceTrustedApiAuthId ?
'Current value hidden' :
''"
/>

<AutosavingTextInput v-if="settingsStore.settings?.linkVideosOnTheBlueAlliance"
:key="`theBlueAllianceTrustedApiAuthSecret-${dataRefreshKey}`"
:on-submit="submit"
initial-value=""
name="theBlueAllianceTrustedApiAuthSecret"
label="TBA trusted API auth secret"
input-type="password"
setting-type="secret"
:help-text="settingsStore.obfuscatedSecrets?.theBlueAllianceTrustedApiAuthId ?
'Current value hidden' :
''"
/>

<h2 class="mt-4">
Expand Down Expand Up @@ -228,7 +281,17 @@ async function saveUploadPrivacy(value: string): Promise<void> {
savingUploadPrivacy.value = false;
}
const theBlueAllianceReadApiKeyHelpText = computed((): string => {
// TODO(Evan): Move into its own component
const savingTbaLinkVideos = ref(false);
async function saveTbaLinkVideos(value: string): Promise<void> {
savingTbaLinkVideos.value = true;
await submit("linkVideosOnTheBlueAlliance", value === "On", "setting");
await refreshData(false);
savingTbaLinkVideos.value = false;
}
const tbaReadApiKeyHelpText = computed((): string => {
const baseText = "Generate a read API key from your account page on The Blue Alliance.";
if (settingsStore.obfuscatedSecrets?.theBlueAllianceReadApiKey) {
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"@types/jasmine": "^4.3.2",
"@types/mock-fs": "^4.13.1",
"@types/morgan": "^1.9.4",
"@types/node": "^18.15.11",
"@types/node": "^20.8.6",
"@types/node-fetch": "2",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "5.43.0",
Expand Down
10 changes: 9 additions & 1 deletion server/settings/secrets.example.json
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
{"googleClientSecret": "", "googleAccessToken": "", "googleRefreshToken": "","theBlueAllianceReadApiKey": ""}
{
"googleClientSecret": "",
"googleAccessToken": "",
"googleRefreshToken": "",
"googleTokenExpiry": "",
"theBlueAllianceReadApiKey": "",
"theBlueAllianceTrustedApiAuthId": "",
"theBlueAllianceTrustedApiAuthSecret": ""
}
12 changes: 11 additions & 1 deletion server/settings/settings.example.json
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
{"eventName":"Demo Event","eventTbaCode":"","videoSearchDirectory":"./videos","googleClientId":"","googleAuthStatus":"Not started", "sandboxModeEnabled": true, "playoffsType":"Double elimination playoff", "youTubeVideoPrivacy": "private"}
{
"eventName": "Demo Event",
"eventTbaCode": "",
"videoSearchDirectory": "./videos",
"googleClientId": "",
"googleAuthStatus": "Not started",
"sandboxModeEnabled": true,
"playoffsType": "Double elimination playoff",
"youTubeVideoPrivacy": "private",
"linkVideosOnTheBlueAlliance": false
}
4 changes: 4 additions & 0 deletions server/spec/tests/repos/JsonStorageRepo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const sampleSettings: ISettings = {
videoSearchDirectory: "./videos",
googleAuthStatus: "",
googleClientId: "",
playoffsType: "Double elimination playoff",
sandboxModeEnabled: false,
youTubeVideoPrivacy: "private",
linkVideosOnTheBlueAlliance: false,
};

describe("readSettingsJson", () => {
Expand Down
5 changes: 0 additions & 5 deletions server/spec/tests/repos/MatchKey.spec.ts

This file was deleted.

18 changes: 18 additions & 0 deletions server/spec/tests/repos/TbaTrustedApi.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { TheBlueAllianceTrustedRepo } from "@src/repos/TheBlueAllianceTrustedRepo";

describe("TBA request signature", () => {
it("should be correct for a sample request", () => {
// This tests the signature for the sample request listed on https://www.thebluealliance.com/apidocs/trusted/v1
// These API credentials are NOT real.

const fakeAuthId = "rosssssssssss";
const fakeAuthSecret =
"ExqeZK3Gbo9v95YnqmsiADzESo9HNgyhIOYSMyRpqJqYv13EazNRaDIPPJuOXrQp"; // This is NOT a real secret!
const tbaTrustedRepo = new TheBlueAllianceTrustedRepo(fakeAuthId, fakeAuthSecret);

const path = "/api/trusted/v1/event/2014casj/matches/delete";
const body = JSON.stringify(["qm1"]);

expect(tbaTrustedRepo.generateRequestSignature(path, body)).toBe("ca5c3e5c1b0e7132e4af13f805eca0be");
});
});
35 changes: 30 additions & 5 deletions server/src/models/MatchKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,46 @@ class MatchKey {
);
}

get matchKey(): string {
private generateMatchKey(includeEventInfo = true): string {
// TODO: We should add tests to ensure this value is correct
const compLevel = abbreviatedCompLevel(this.compLevel);
const setNumber = this.setNumber === null ? "" : this.setNumber;

return `${this.year}${this.eventCode}_${compLevel}${setNumber}m${this.matchNumber}`;
const eventInfo = includeEventInfo ? `${this.year}${this.eventCode}_` : "";

return `${eventInfo}${compLevel}${setNumber}m${this.matchNumber}`;
}

/**
* Compute the event key for this match. Example: 2020gadal
*/
get eventKey(): string {
return `${this.year}${this.eventCode}`;
}

/**
* Get the full match key for this match. Example: 2020gadal_qm1
*/
get matchKey(): string {
return this.generateMatchKey(true);
}

/**
* Compute the match number portion of the match key. Used for The Blue Alliance's trusted (write) API, which
* refers to this as a partial match key.
* Example: qm1
*/
get partialMatchKey(): string {
return this.generateMatchKey(false);
}

/**
* Get the playoff round for this match, if we're able to calculate it.
*
* @returns The playoff round, or null if:
* - The match is a qualification match
* - There is no set number for the match
* - The match is a playoff match, but the playoffs type is not double elimination
* - The match is a qualification match, or
* - There is no set number for the match, or
* - The match is a playoff match, but the playoffs type is not double elimination.
*/
get playoffsRound(): number | null {
if (this.playoffsType === PlayoffsType.DoubleElimination && this.compLevel === CompLevel.Semifinal &&
Expand Down
3 changes: 3 additions & 0 deletions server/src/models/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ISettings {
playoffsType: string,
sandboxModeEnabled: boolean,
youTubeVideoPrivacy: string,
linkVideosOnTheBlueAlliance: boolean;
}

export type SettingsKey = keyof ISettings;
Expand All @@ -17,6 +18,8 @@ export interface ISecretSettings {
googleRefreshToken: string,
googleTokenExpiry: string,
theBlueAllianceReadApiKey: string,
theBlueAllianceTrustedApiAuthId: string,
theBlueAllianceTrustedApiAuthSecret: string,
[key: string]: string,
}

Expand Down
2 changes: 2 additions & 0 deletions server/src/models/YouTubePostUploadSteps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export interface YouTubePostUploadSteps {
// Indicates if the video was successfully added to the YouTube playlist corresponding to the video's label
addToYouTubePlaylist: boolean,
// Indicates if the video was successfully associated with the match to the Blue Alliance
linkOnTheBlueAlliance: boolean,
}
Loading

0 comments on commit b4073f8

Please sign in to comment.