From 9392eec29278bb43f72f2786fdb57aa9bb3d455b Mon Sep 17 00:00:00 2001 From: Evan Strat <5790137+evan10s@users.noreply.github.com> Date: Tue, 15 Oct 2024 00:37:26 -0400 Subject: [PATCH] Auto Rename v1 (#129) Co-authored-by: Evan Strat --- .github/ISSUE_TEMPLATE/1-auto_rename.yaml | 69 ++ .../ISSUE_TEMPLATE/{1-bug.yaml => 2-bug.yaml} | 0 .../{2-feedback.yaml => 3-feedback.yaml} | 0 Dockerfile | 7 +- client/.eslintrc.js | 1 + client/package.json | 16 +- client/src/App.vue | 5 + .../components/alerts/AutoRenameEnabled.vue | 25 + client/src/components/alerts/GlobalAlerts.vue | 4 + .../components/alerts/LiveModeActivated.vue | 23 + .../src/components/autoRename/AutoRename.vue | 112 +++ .../autoRename/AutoRenameAssociations.vue | 188 ++++ .../autoRename/AutoRenameFileNamePatterns.vue | 112 +++ .../AutoRenameReviewDialogContents.vue | 360 ++++++++ client/src/components/jobs/JobListItem.vue | 9 +- client/src/components/jobs/JobsList.vue | 14 +- client/src/components/liveMode/LiveMode.vue | 3 +- .../matches/MatchAutocompleteDropdown.vue | 40 + .../src/components/matches/MatchSelector.vue | 26 +- .../components/matches/MatchVideoListItem.vue | 2 +- client/src/components/nav/NavDrawer.vue | 18 +- client/src/router/index.ts | 9 + client/src/stores/autoRename.ts | 230 +++++ client/src/stores/worker.ts | 12 +- client/src/style/global.css | 1 + client/src/types/ISettings.ts | 7 + client/src/types/WorkerJob.ts | 5 + .../types/autoRename/AutoRenameAssociation.ts | 78 ++ .../autoRename/AutoRenameAssociationStatus.ts | 51 + client/src/views/AutoRenameView.vue | 10 + client/src/views/Home.vue | 9 +- client/src/views/Settings.vue | 77 +- client/src/views/Worker.vue | 5 +- client/tsconfig.node.json | 4 +- client/{vite.config.ts => vite.config.mts} | 0 client/yarn.lock | 873 ++++++++++-------- docker-compose.yaml | 3 + server/README.md | 8 +- server/env/development.env.example | 1 + server/package.json | 11 +- .../migration.sql | 45 + server/prisma/migrations/migration_lock.toml | 3 + server/prisma/schema.prisma | 54 ++ server/settings/settings.example.json | 9 +- server/spec/tests/repos/Match.spec.ts | 163 ++++ server/spec/tests/util.ts | 16 +- server/src/constants/EnvVars.ts | 1 + server/src/crontab.txt | 1 + server/src/declare.d.ts | 1 + server/src/models/CompLevel.ts | 32 + server/src/models/Match.ts | 111 ++- server/src/models/Settings.ts | 7 + server/src/models/frcEvents/frcScoredMatch.ts | 3 + .../tbaMatchesSimpleApiResponse.ts | 7 + server/src/repos/AutoRenameRepo.ts | 53 ++ server/src/repos/FileStorageRepo.ts | 9 +- server/src/repos/FrcEventsRepo.ts | 4 +- server/src/routes/api.ts | 2 + server/src/routes/autoRename.ts | 158 ++++ server/src/routes/constants/Paths.ts | 14 + server/src/routes/matches.ts | 152 +-- server/src/routes/worker.ts | 18 + server/src/services/AutoRenameService.ts | 272 ++++++ server/src/services/WorkerService.ts | 56 +- server/src/services/YouTubeService.ts | 6 +- server/src/tasks/autoRename.ts | 405 ++++++++ server/src/tasks/renameFile.ts | 64 ++ server/src/tasks/types/cronPayload.ts | 16 + server/src/tasks/types/events.ts | 34 +- server/src/tasks/types/tasks.ts | 4 +- server/src/util/queueJob.ts | 59 ++ server/src/util/ws.ts | 13 +- server/src/worker.ts | 31 +- server/yarn.lock | 163 ++-- 74 files changed, 3728 insertions(+), 686 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/1-auto_rename.yaml rename .github/ISSUE_TEMPLATE/{1-bug.yaml => 2-bug.yaml} (100%) rename .github/ISSUE_TEMPLATE/{2-feedback.yaml => 3-feedback.yaml} (100%) create mode 100644 client/src/components/alerts/AutoRenameEnabled.vue create mode 100644 client/src/components/alerts/LiveModeActivated.vue create mode 100644 client/src/components/autoRename/AutoRename.vue create mode 100644 client/src/components/autoRename/AutoRenameAssociations.vue create mode 100644 client/src/components/autoRename/AutoRenameFileNamePatterns.vue create mode 100644 client/src/components/autoRename/AutoRenameReviewDialogContents.vue create mode 100644 client/src/components/matches/MatchAutocompleteDropdown.vue create mode 100644 client/src/stores/autoRename.ts create mode 100644 client/src/types/autoRename/AutoRenameAssociation.ts create mode 100644 client/src/types/autoRename/AutoRenameAssociationStatus.ts create mode 100644 client/src/views/AutoRenameView.vue rename client/{vite.config.ts => vite.config.mts} (100%) create mode 100644 server/prisma/migrations/20241014044115_add_auto_rename/migration.sql create mode 100644 server/prisma/migrations/migration_lock.toml create mode 100644 server/spec/tests/repos/Match.spec.ts create mode 100644 server/src/crontab.txt create mode 100644 server/src/declare.d.ts create mode 100644 server/src/repos/AutoRenameRepo.ts create mode 100644 server/src/routes/autoRename.ts create mode 100644 server/src/services/AutoRenameService.ts create mode 100644 server/src/tasks/autoRename.ts create mode 100644 server/src/tasks/renameFile.ts create mode 100644 server/src/tasks/types/cronPayload.ts create mode 100644 server/src/util/queueJob.ts diff --git a/.github/ISSUE_TEMPLATE/1-auto_rename.yaml b/.github/ISSUE_TEMPLATE/1-auto_rename.yaml new file mode 100644 index 0000000..02da532 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-auto_rename.yaml @@ -0,0 +1,69 @@ +name: Auto Rename association issues +description: Inaccurate or unexpected associations made by the Auto Rename feature +labels: ["area / auto rename", "type / bug", "status / grooming"] +body: + - type: dropdown + attributes: + label: Association problem type + options: + - Select a value... + - Strong association to the wrong match + - Association status unexpectedly weak + - Association was expected but not made + - Something else (describe in Additional Information) + - type: input + attributes: + label: Video file path + description: Relative path of the video file (within the videos directory) that was incorrectly associated. If multiple related instances, add more details in the Additional Information section below + validations: + required: false + - type: input + attributes: + label: Expected match key + description: The full match key (e.g., 2024gadal_qm1) of the match that should have been associated with the video file + validations: + required: true + - type: input + attributes: + label: Actual associated match key + description: The full match key (e.g., 2024gadal_qm1) of the match that Auto Rename associated with the video file. Enter N/A if no association was made, but you were expecting one. + validations: + required: false + - type: dropdown + attributes: + label: Association status + description: The full match key (e.g., 2024gadal_qm1) of the match that Auto Rename associated with the video file. Enter N/A if no association was made, but you were expecting one. + options: + - Select a value... + - Unmatched + - Strong + - Weak + - Failed + - Ignored + validations: + required: true + - type: textarea + attributes: + label: Additional information + description: For instance, a screenshot of the association review dialog, errors you saw, etc. + validations: + required: true + - type: input + attributes: + label: Match Uploader version + validations: + required: true + - type: dropdown + attributes: + label: Are you running Match Uploader using the official Docker Compose setup? + options: + - Select a value... + - "Yes" + - "No" + validations: + required: true + - type: textarea + attributes: + label: Relevant error or log output + description: If there are logs or errors related to this problem, include them here. Make sure to note which component the logs came from. This will be automatically formatted into code, so no need for backticks. + render: shell diff --git a/.github/ISSUE_TEMPLATE/1-bug.yaml b/.github/ISSUE_TEMPLATE/2-bug.yaml similarity index 100% rename from .github/ISSUE_TEMPLATE/1-bug.yaml rename to .github/ISSUE_TEMPLATE/2-bug.yaml diff --git a/.github/ISSUE_TEMPLATE/2-feedback.yaml b/.github/ISSUE_TEMPLATE/3-feedback.yaml similarity index 100% rename from .github/ISSUE_TEMPLATE/2-feedback.yaml rename to .github/ISSUE_TEMPLATE/3-feedback.yaml diff --git a/Dockerfile b/Dockerfile index 4c57895..2ce6efc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ COPY client/public ./public COPY client/src ./src COPY client/index.html . COPY client/tsconfig*.json . -COPY client/vite.config.ts . +COPY client/vite.config.mts . RUN yarn run build @@ -47,6 +47,8 @@ COPY server/build.ts . RUN yarn run build +COPY server/src/crontab.txt ./dist/crontab.txt + FROM base as run_server_prod WORKDIR /home/node/app/server @@ -86,5 +88,8 @@ RUN chown -R node:node /home/node/app/server/settings && \ chown -R node:node /home/node/app/server/env USER node + +# TODO: Allow customizing TZ +ENV TZ="America/New_York" EXPOSE 8080 CMD ["yarn", "start"] diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 363ee00..2dffb16 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -37,5 +37,6 @@ module.exports = { "vue/no-multiple-template-root": "off", "vue/singleline-html-element-content-newline": "off", "vue/v-slot-style": "off", + "vue/valid-v-slot": "off", }, }; diff --git a/client/package.json b/client/package.json index dbc0c33..a2442c7 100644 --- a/client/package.json +++ b/client/package.json @@ -13,26 +13,26 @@ "@mdi/font": "7.2.96", "@vueuse/core": "^10.9.0", "dayjs": "^1.11.10", - "pinia": "^2.1.6", + "pinia": "^2.2.2", "roboto-fontface": "*", "socket.io-client": "^4.7.2", "swrv": "^1.0.4", - "vue": "^3.3.4", - "vue-router": "^4.2.4", - "vuetify": "^3.3.11", + "vue": "^3.5.8", + "vue-router": "^4.4.5", + "vuetify": "^3.7.2", "webfontloader": "^1.6.28" }, "devDependencies": { "@types/node": "^20.4.8", "@types/webfontloader": "^1.6.35", - "@vitejs/plugin-vue": "^4.2.3", + "@vitejs/plugin-vue": "^5.1.4", "@vue/eslint-config-typescript": "^11.0.3", "eslint": "^8.46.0", "eslint-plugin-vue": "^9.16.1", "typescript": "^5.1.6", - "vite": "^4.4.8", - "vite-plugin-vuetify": "^1.0.2", - "vue-tsc": "^1.8.19" + "vite": "^5.4.7", + "vite-plugin-vuetify": "^2.0.4", + "vue-tsc": "^1.8.27" }, "license": "GPL-3.0" } diff --git a/client/src/App.vue b/client/src/App.vue index c9ed649..98335ed 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -6,14 +6,19 @@ import MainLayout from "@/layouts/Main.vue"; import {useWorkerStore} from "@/stores/worker"; import { socket } from "@/socket"; +import { useAutoRenameStore } from "@/stores/autoRename"; +// TODO: Should we pull out websocket logic into its own store? const workerStore = useWorkerStore(); +const autoRenameStore = useAutoRenameStore(); // Adapted from https://socket.io/how-to/use-with-vue#with-pinia // remove any existing listeners (after a hot module replacement) socket.off(); +// TODO: Unify websocket connection monitoring in the future workerStore.bindEvents(); +autoRenameStore.bindEvents(); diff --git a/client/src/components/alerts/AutoRenameEnabled.vue b/client/src/components/alerts/AutoRenameEnabled.vue new file mode 100644 index 0000000..539fbc3 --- /dev/null +++ b/client/src/components/alerts/AutoRenameEnabled.vue @@ -0,0 +1,25 @@ + + diff --git a/client/src/components/alerts/GlobalAlerts.vue b/client/src/components/alerts/GlobalAlerts.vue index e9506a7..ed14bb4 100644 --- a/client/src/components/alerts/GlobalAlerts.vue +++ b/client/src/components/alerts/GlobalAlerts.vue @@ -5,6 +5,8 @@ + + @@ -14,4 +16,6 @@ import YouTubeAuthIncompleteAlert from "@/components/alerts/YouTubeAuthIncomplet import NoPlaylistMappings from "@/components/alerts/NoPlaylistMappings.vue"; import PrivateUploads from "@/components/alerts/PrivateUploads.vue"; import FrcEventsWarning from "@/components/alerts/FrcEventsWarning.vue"; +import AutoRenameEnabled from "@/components/alerts/AutoRenameEnabled.vue"; +import LiveModeActivated from "@/components/alerts/LiveModeActivated.vue"; diff --git a/client/src/components/alerts/LiveModeActivated.vue b/client/src/components/alerts/LiveModeActivated.vue new file mode 100644 index 0000000..6ac572e --- /dev/null +++ b/client/src/components/alerts/LiveModeActivated.vue @@ -0,0 +1,23 @@ + + diff --git a/client/src/components/autoRename/AutoRename.vue b/client/src/components/autoRename/AutoRename.vue new file mode 100644 index 0000000..8913dfc --- /dev/null +++ b/client/src/components/autoRename/AutoRename.vue @@ -0,0 +1,112 @@ + + + diff --git a/client/src/components/autoRename/AutoRenameAssociations.vue b/client/src/components/autoRename/AutoRenameAssociations.vue new file mode 100644 index 0000000..e6607c7 --- /dev/null +++ b/client/src/components/autoRename/AutoRenameAssociations.vue @@ -0,0 +1,188 @@ + + + + diff --git a/client/src/components/autoRename/AutoRenameFileNamePatterns.vue b/client/src/components/autoRename/AutoRenameFileNamePatterns.vue new file mode 100644 index 0000000..2aa2e32 --- /dev/null +++ b/client/src/components/autoRename/AutoRenameFileNamePatterns.vue @@ -0,0 +1,112 @@ + + diff --git a/client/src/components/autoRename/AutoRenameReviewDialogContents.vue b/client/src/components/autoRename/AutoRenameReviewDialogContents.vue new file mode 100644 index 0000000..1bd03e9 --- /dev/null +++ b/client/src/components/autoRename/AutoRenameReviewDialogContents.vue @@ -0,0 +1,360 @@ + + + diff --git a/client/src/components/jobs/JobListItem.vue b/client/src/components/jobs/JobListItem.vue index 1bc9cd0..f80adb6 100644 --- a/client/src/components/jobs/JobListItem.vue +++ b/client/src/components/jobs/JobListItem.vue @@ -2,7 +2,7 @@ - #{{ job.jobId }} {{ job.task }}: {{ job.title }} + #{{ job.jobId }} {{ job.task }}{{ job.title !== "Unknown" ? `: ${job.title}` : "" }} {{ subtitle }} diff --git a/client/src/components/liveMode/LiveMode.vue b/client/src/components/liveMode/LiveMode.vue index 8cf8483..5c8a759 100644 --- a/client/src/components/liveMode/LiveMode.vue +++ b/client/src/components/liveMode/LiveMode.vue @@ -175,8 +175,7 @@ import { useLiveModeStore } from "@/stores/liveMode"; import { liveModeRequirementToUiString } from "@/types/liveMode/LiveModeRequirements"; import TaskListItem from "@/components/util/TaskListItem.vue"; import dayjs from "dayjs"; -import { LiveModeStatus, liveModeStatusToUiString } from "@/types/liveMode/LiveModeStatus"; -import { computed } from "vue"; +import { LiveModeStatus } from "@/types/liveMode/LiveModeStatus"; const liveMode = useLiveModeStore(); diff --git a/client/src/components/matches/MatchAutocompleteDropdown.vue b/client/src/components/matches/MatchAutocompleteDropdown.vue new file mode 100644 index 0000000..fd42898 --- /dev/null +++ b/client/src/components/matches/MatchAutocompleteDropdown.vue @@ -0,0 +1,40 @@ + + diff --git a/client/src/components/matches/MatchSelector.vue b/client/src/components/matches/MatchSelector.vue index 972774f..1aac69c 100644 --- a/client/src/components/matches/MatchSelector.vue +++ b/client/src/components/matches/MatchSelector.vue @@ -21,19 +21,19 @@ Note: this component may be laggy when running in development, but in production builds the performance should become significantly better. --> - { } if (workerStore.jobHasStatus(props.video.workerJobId, WorkerJobStatus.FAILED)) { - return job.error ?? "Unknown error"; // TODO: error might not be set + return job.error ?? "Unknown error"; } if (workerStore.jobHasStatus(props.video.workerJobId, WorkerJobStatus.FAILED_RETRYABLE)) { diff --git a/client/src/components/nav/NavDrawer.vue b/client/src/components/nav/NavDrawer.vue index c9bc726..1dab882 100644 --- a/client/src/components/nav/NavDrawer.vue +++ b/client/src/components/nav/NavDrawer.vue @@ -16,7 +16,6 @@ @@ -32,8 +31,11 @@ import {computed, ref} from "vue"; import {INavItem} from "@/types/INavItem"; import {useWorkerStore} from "@/stores/worker"; import {WorkerJobStatus} from "@/types/WorkerJob"; +import { useAutoRenameStore } from "@/stores/autoRename"; +import { AutoRenameAssociationStatus } from "@/types/autoRename/AutoRenameAssociationStatus"; const workerStore = useWorkerStore(); +const autoRenameStore = useAutoRenameStore(); const navItems = computed(() => { return [ @@ -42,6 +44,20 @@ const navItems = computed(() => { to: "/upload", icon: "mdi-cloud-upload-outline", }, + { + title: "Auto rename", + to: "/autoRename", + icon: "mdi-auto-mode", + badge: { + show: autoRenameStore.associationsInStatus( + [AutoRenameAssociationStatus.WEAK, AutoRenameAssociationStatus.FAILED], + ).length > 0, + color: "warning", + content: autoRenameStore.associationsInStatus( + [AutoRenameAssociationStatus.WEAK, AutoRenameAssociationStatus.FAILED], + ).length, + }, + }, { title: "Worker queue", to: "/worker", diff --git a/client/src/router/index.ts b/client/src/router/index.ts index caa9cf6..8aa9232 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -15,6 +15,15 @@ const routes: RouteRecordRaw[] = [ // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "home" */ "@/views/Home.vue"), }, + { + path: "/autoRename", + name: "Auto rename", + // redirect: "/autoRename", + // route level code-splitting + // this generates a separate chunk (about.[hash].js) for this route + // which is lazy-loaded when the route is visited. + component: () => import(/* webpackChunkName: "home" */ "@/views/AutoRenameView.vue"), + }, { path: "/settings", name: "Settings", diff --git a/client/src/stores/autoRename.ts b/client/src/stores/autoRename.ts new file mode 100644 index 0000000..6d19273 --- /dev/null +++ b/client/src/stores/autoRename.ts @@ -0,0 +1,230 @@ +import { acceptHMRUpdate, defineStore } from "pinia"; +import { AutoRenameAssociationStatus } from "@/types/autoRename/AutoRenameAssociationStatus"; +import { computed, ref, watch } from "vue"; +import { + AutoRenameAssociation, + isAutoRenameAssociation, + isAutoRenameAssociationApiResponse, +} from "@/types/autoRename/AutoRenameAssociation"; +import { socket } from "@/socket"; + +export const useAutoRenameStore = defineStore("autoRename", () => { + const associations = ref>(new Map()); + const associationsList = computed(() => Array.from(associations.value.values())); + const loadingAssociations = ref(false); + const associationsError = ref(""); + + function handleGetAssociationsError(result: Response, message: string) { + if (!result.ok) { + associationsError.value = `API error (${result.status} ${result.statusText}): ${message}`; + return true; + } + + return false; + } + + async function getAssociations() { + loadingAssociations.value = true; + associationsError.value = ""; + + const url = "/api/v1/autoRename/associations"; + const result = await fetch(url); + + if (handleGetAssociationsError(result, "Unable to load associations")) { + loadingAssociations.value = false; + return; + } + + const resultJson = await result.json(); + + if (!isAutoRenameAssociationApiResponse(resultJson)) { + associationsError.value = "API error: Match associations response is invalid"; + loadingAssociations.value = false; + return; + } + + for (const association of resultJson.associations) { + associations.value.set(association.filePath, association); + } + + loadingAssociations.value = false; + } + + function associationsInStatus(statuses: AutoRenameAssociationStatus | AutoRenameAssociationStatus[]) { + if (!Array.isArray(statuses)) { + statuses = [statuses]; + } + + return associationsList.value.filter((association) => statuses.includes(association.status)); + } + + const confirmWeakAssociationError = ref(""); + const confirmWeakAssociationLoading = ref(false); + + async function confirmWeakAssociation(association: AutoRenameAssociation, newMatchKey: string | null = null) { + confirmWeakAssociationError.value = ""; + confirmWeakAssociationLoading.value = true; + const matchKeyExtra = newMatchKey ? { matchKey: newMatchKey } : {}; + + const response = await fetch("/api/v1/autoRename/associations/confirm", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filePath: association.filePath, + videoLabel: association.videoLabel, + ...matchKeyExtra, + }), + }) + .catch((error) => { + confirmWeakAssociationError.value = `Unable to confirm association: ${error}`; + confirmWeakAssociationLoading.value = false; + return null; + }); + + if (!response) { + return; + } + + if (!response.ok) { + const errorResponse = await response.json(); + confirmWeakAssociationError.value = errorResponse.message || "Unable to confirm association"; + } + confirmWeakAssociationLoading.value = false; + } + + const ignoreAssociationError = ref(""); + const ignoreAssociationLoading = ref(false); + + async function ignoreAssociation(association: AutoRenameAssociation) { + console.log("Ignoring association", association); + ignoreAssociationError.value = ""; + ignoreAssociationLoading.value = true; + const response = await fetch("/api/v1/autoRename/associations/ignore", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filePath: association.filePath, + videoLabel: association.videoLabel, + }), + }) + .catch((error) => { + ignoreAssociationError.value = `Unable to ignore association: ${error}`; + ignoreAssociationLoading.value = false; + return null; + }); + + if (!response) { + return; + } + + if (!response.ok) { + const errorResponse = await response.json(); + ignoreAssociationError.value = errorResponse.message || "Unable to ignore association"; + } + ignoreAssociationLoading.value = false; + } + + const undoRenameError = ref(""); + const undoRenameLoading = ref(false); + + async function undoRename(association: AutoRenameAssociation) { + undoRenameError.value = ""; + undoRenameLoading.value = true; + const response = await fetch("/api/v1/autoRename/associations/undoRename", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filePath: association.filePath, + }), + }) + .catch((error) => { + undoRenameError.value = `Unable to undo rename: ${error}`; + undoRenameLoading.value = false; + return null; + }); + + if (!response) { + return; + } + + if (!response.ok) { + const errorResponse = await response.json(); + undoRenameError.value = errorResponse.message || "Unable to undo rename"; + } + undoRenameLoading.value = false; + } + + function isEditable(association: AutoRenameAssociation) { + if (association.status === AutoRenameAssociationStatus.STRONG) { + const editable = !association.renameCompleted; + return editable; + } + + return true; + } + + const isConnected = ref(false); + const isInitialConnectionPending = ref(true); + + watch(isConnected, (newValue) => { + console.log("isConnected changed to", newValue); + }); + + /** + * Binds Socket.IO listeners + * + * Adapted from https://socket.io/how-to/use-with-vue#with-pinia + */ + function bindEvents() { + socket.on("connect", () => { + isConnected.value = true; + isInitialConnectionPending.value = false; + console.log("Connected to socket.io server!"); + }); + + socket.on("disconnect", () => { + isConnected.value = false; + }); + + socket.on("autorename", async (payload) => { + console.log("Received autorename event", payload.association); + if (!isAutoRenameAssociation(payload.association)) { + console.warn("Ignoring invalid autorename event", payload); + return; + } + + associations.value.set(payload.association.filePath, payload.association); + }); + } + + return { + associations: associationsList, + associationsMap: associations, + associationsInStatus, + associationsError, + bindEvents, + confirmWeakAssociation, + confirmWeakAssociationError, + confirmWeakAssociationLoading, + getAssociations, + ignoreAssociation, + ignoreAssociationError, + ignoreAssociationLoading, + isEditable, + loadingAssociations, + undoRename, + undoRenameError, + undoRenameLoading, + }; +}); + +// Enable Pinia's hot module replacement feature +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useAutoRenameStore, import.meta.hot)); +} diff --git a/client/src/stores/worker.ts b/client/src/stores/worker.ts index d5c5f13..950e472 100644 --- a/client/src/stores/worker.ts +++ b/client/src/stores/worker.ts @@ -4,7 +4,7 @@ import {computed, ref, watch} from "vue"; import { isWorkerEvent, isWorkerJobCompleteEvent, isWorkerJobCreatedEvent, - isWorkerJobStartEvent, WORKER_JOB_CREATED, WorkerEvent, WorkerEvents, + isWorkerJobStartEvent, WorkerEvent, WorkerTask, WorkerJob, WorkerJobEvent, WorkerJobStatus, workerJobStatusAsNumber, @@ -60,6 +60,14 @@ export const useWorkerStore = defineStore("worker", () => { const jobsLoading = ref(false); const jobsError = ref(""); + function jobsForTask(tasks: WorkerTask | WorkerTask[]) { + if (!Array.isArray(tasks)) { + tasks = [tasks]; + } + + return jobsList.value.filter((job) => tasks.includes(job.task)); + } + function jobsInStatus(statuses: WorkerJobStatus | WorkerJobStatus[]) { if (!Array.isArray(statuses)) { statuses = [statuses]; @@ -112,7 +120,6 @@ export const useWorkerStore = defineStore("worker", () => { } async function loadJobs() { - // Load jobs by fetching from /api/v1/worker/jobs jobsLoading.value = true; jobsError.value = ""; @@ -240,6 +247,7 @@ export const useWorkerStore = defineStore("worker", () => { jobHasStatus, jobs, jobsError, + jobsForTask, jobsList, jobsListAsQueue, jobsLoading, diff --git a/client/src/style/global.css b/client/src/style/global.css index edbf603..2f868e0 100644 --- a/client/src/style/global.css +++ b/client/src/style/global.css @@ -16,6 +16,7 @@ body { /* https://stackoverflow.com/a/59769716 */ .force-text-wrap { -webkit-line-clamp: unset !important; + white-space: inherit !important; } .v-card-title { diff --git a/client/src/types/ISettings.ts b/client/src/types/ISettings.ts index 4b7123c..b69dea1 100644 --- a/client/src/types/ISettings.ts +++ b/client/src/types/ISettings.ts @@ -12,6 +12,13 @@ export interface ISettings { youTubeVideoPrivacy: string, linkVideosOnTheBlueAlliance: boolean; useFrcEventsApi: boolean; + autoRenameEnabled: boolean; + autoRenameMinExpectedVideoDurationSecs: string; + autoRenameMaxExpectedVideoDurationSecs: string; + autoRenameMaxStartTimeDiffSecStrong: string; + autoRenameMaxStartTimeDiffSecWeak: string; + autoRenameFileRenameJobDelaySecs: string; + autoRenameFileNamePatterns: string; [key: string]: string | boolean, } diff --git a/client/src/types/WorkerJob.ts b/client/src/types/WorkerJob.ts index 940f010..d31bbc4 100644 --- a/client/src/types/WorkerJob.ts +++ b/client/src/types/WorkerJob.ts @@ -4,6 +4,11 @@ export const WORKER_JOB_COMPLETE = "worker:job:complete"; export type WorkerEvent = typeof WORKER_JOB_CREATED | typeof WORKER_JOB_START | typeof WORKER_JOB_COMPLETE; +export const UPLOAD_VIDEO_TASK = "uploadVideo"; +export const RENAME_VIDEO_TASK = "renameVideo"; +export const AUTO_RENAME_TASK = "autoRename"; + +export const WorkerTask = typeof UPLOAD_VIDEO_TASK | typeof RENAME_VIDEO_TASK | typeof AUTO_RENAME_TASK; export enum WorkerJobStatus { PENDING = "PENDING", diff --git a/client/src/types/autoRename/AutoRenameAssociation.ts b/client/src/types/autoRename/AutoRenameAssociation.ts new file mode 100644 index 0000000..a8ff247 --- /dev/null +++ b/client/src/types/autoRename/AutoRenameAssociation.ts @@ -0,0 +1,78 @@ +import { + AutoRenameAssociationStatus, + isAutoRenameAssociationStatus, +} from "@/types/autoRename/AutoRenameAssociationStatus"; + +export interface AutoRenameAssociationApiResponse { + ok: boolean; + associations: AutoRenameAssociation[]; +} + +export interface AutoRenameAssociation { + filePath: string; + videoFile: string; + status: AutoRenameAssociationStatus; + statusReason: string | null; + videoTimestamp: string | null; + associationAttempts: number; + maxAssociationAttempts: number; + matchKey: string | null; + matchName: string | null; + videoLabel: string | null; + newFileName: string | null; + createdAt: string; + updatedAt: string; + renameJobId: string | null; + renameAfter: string | null; + renameCompleted: boolean; + isIgnored: boolean; + videoDurationSecs: number | null; + startTimeDiffSecs: number | null; + startTimeDiffAbnormal: boolean | null; + videoDurationAbnormal: boolean | null; + orderingIssueMatchKey: string | null; + orderingIssueMatchName: string | null; +} + +export function isAutoRenameAssociationApiResponse(x: unknown): x is AutoRenameAssociationApiResponse { + return x !== null && + typeof x === "object" && + (x as AutoRenameAssociationApiResponse).ok !== undefined && + (x as AutoRenameAssociationApiResponse).associations !== undefined && + Array.isArray((x as AutoRenameAssociationApiResponse).associations) && + (x as AutoRenameAssociationApiResponse).associations.every(isAutoRenameAssociation); +} + +export function isAutoRenameAssociation(x: unknown): x is AutoRenameAssociation { + return typeof x === "object" && + "filePath" in x && + "status" in x && + isAutoRenameAssociationStatus(x.status) && + "statusReason" in x && + "videoTimestamp" in x && + "associationAttempts" in x && + "maxAssociationAttempts" in x && + "videoLabel" in x && + "renameJobId" in x && + "renameAfter" in x && + "renameCompleted" in x && + "newFileName" in x && + "createdAt" in x && + "updatedAt" in x && + "matchKey" in x && + "matchName" in x && + "videoDurationSecs" in x && + "startTimeDiffSecs" in x && + "startTimeDiffAbnormal" in x && + "videoDurationAbnormal" in x && + "orderingIssueMatchKey" in x && + "orderingIssueMatchName" in x; +} + +export const AUTO_RENAME_ASSOCIATION_UPDATE = "autorename:association:update"; +export type AutoRenameEvent = typeof AUTO_RENAME_ASSOCIATION_UPDATE; + +export interface AutoRenameEvents { + event: EventName; + association: AutoRenameAssociation; +} diff --git a/client/src/types/autoRename/AutoRenameAssociationStatus.ts b/client/src/types/autoRename/AutoRenameAssociationStatus.ts new file mode 100644 index 0000000..87cc3d3 --- /dev/null +++ b/client/src/types/autoRename/AutoRenameAssociationStatus.ts @@ -0,0 +1,51 @@ +export enum AutoRenameAssociationStatus { + // No association has been made yet + UNMATCHED = "UNMATCHED", + // Max attempts for associations was reached + FAILED = "FAILED", + // A weak association (requires human review) was made + WEAK = "WEAK", + // A strong association was made (manually set by a human, manually approved by a human, or automatically classified + // as strong) + STRONG = "STRONG", + // The association was manually ignored and won't be matched + IGNORED = "IGNORED", +} + +/** + * Converts an AutoRenameAssociationStatus to a string suitable for display in the UI. Will always return a lowercase, + * so you may need to adjust capitalization depending on the context. + * @param status + */ +export function autoRenameAssociationStatusToUiString(status: AutoRenameAssociationStatus) { + const outputMap: Record = { + [AutoRenameAssociationStatus.UNMATCHED]: "unmatched", + [AutoRenameAssociationStatus.FAILED]: "failed", + [AutoRenameAssociationStatus.WEAK]: "weak", + [AutoRenameAssociationStatus.STRONG]: "strong", + [AutoRenameAssociationStatus.IGNORED]: "ignored", + }; + + return outputMap[status]; +} + +export function isAutoRenameAssociationStatus(x: unknown): x is AutoRenameAssociationStatus { + return Object.values(AutoRenameAssociationStatus).includes(x as AutoRenameAssociationStatus); +} + +/** + * Converts an AutoRenameAssociationStatus to a color string suitable for display in the UI. Returns null if the status + * should not be colored. + * @param status + */ +export function autoRenameAssociationStatusToColor(status: AutoRenameAssociationStatus) { + const outputMap: Record = { + [AutoRenameAssociationStatus.UNMATCHED]: null, + [AutoRenameAssociationStatus.FAILED]: "error", + [AutoRenameAssociationStatus.WEAK]: "warning", + [AutoRenameAssociationStatus.STRONG]: "success", + [AutoRenameAssociationStatus.IGNORED]: null, + }; + + return outputMap[status]; +} diff --git a/client/src/views/AutoRenameView.vue b/client/src/views/AutoRenameView.vue new file mode 100644 index 0000000..c0c2052 --- /dev/null +++ b/client/src/views/AutoRenameView.vue @@ -0,0 +1,10 @@ + + diff --git a/client/src/views/Home.vue b/client/src/views/Home.vue index 2df6a19..5a0c5f1 100644 --- a/client/src/views/Home.vue +++ b/client/src/views/Home.vue @@ -58,12 +58,12 @@ - -

Worker queue

- +

Upload queue

+
@@ -83,6 +83,7 @@ import JobsList from "@/components/jobs/JobsList.vue"; import {useWorkerStore} from "@/stores/worker"; import MatchMetadata from "@/components/matches/MatchMetadata.vue"; import LiveMode from "@/components/liveMode/LiveMode.vue"; +import { UPLOAD_VIDEO_TASK } from "@/types/WorkerJob"; const error = ref(""); @@ -101,7 +102,7 @@ const videosMdColWidth = computed(() => { -