From d27e065680f7de0dcc32ee1a794df6f28cda6ed1 Mon Sep 17 00:00:00 2001 From: Saelmala Date: Fri, 23 Aug 2024 09:24:02 +0200 Subject: [PATCH] chore: merge with main --- .env.sample | 13 +- .github/workflows/dispatch.yml | 59 +++++ .github/workflows/release.yml | 58 +++++ Dockerfile | 14 +- README.md | 16 +- RELEASE.md | 23 ++ docs/production_workflow.md | 2 +- package-lock.json | 101 +++++--- package.json | 10 +- scripts/mongo-init.js | 4 +- scripts/mongo-mock.js | 14 +- .../pipelines/multiviews/multiviews.ts | 207 ---------------- src/api/agileLive/utils/authheader.ts | 8 - .../controlconnections.ts | 10 +- .../controlpanels.ts | 8 +- src/api/{agileLive => ateliereLive}/ingest.ts | 41 +++- .../pipelines/multiviews/multiviews.ts | 226 ++++++++++++++++++ .../pipelines/outputs/outputs.ts | 20 +- .../pipelines/pipelines.ts | 20 +- .../pipelines/streams/streams.ts | 120 ++++++---- .../{agileLive => ateliereLive}/streams.ts | 8 +- src/api/ateliereLive/utils/authheader.ts | 8 + .../utils/fwConfigPorts.test.data.ts | 0 .../utils/fwConfigPorts.test.ts | 0 .../utils/fwConfigPorts.ts | 0 .../utils/multiview.ts | 0 .../utils/pipeline.ts | 2 +- src/api/ateliereLive/websocket.ts | 45 ++++ src/api/manager/inventory.ts | 21 +- src/api/manager/job/syncInventory.ts | 118 +++++---- src/api/manager/job/syncMonitoring.ts | 13 +- src/api/manager/productions.ts | 1 + src/api/manager/sources.ts | 51 ++-- src/api/manager/workflow.ts | 85 +++++-- src/app/api/manager/controlpanels/route.ts | 2 +- src/app/api/manager/inventory/[_id]/route.ts | 39 +++ src/app/api/manager/multiviews/[id]/route.ts | 2 +- src/app/api/manager/pipelines/[id]/route.ts | 4 +- src/app/api/manager/pipelines/route.ts | 2 +- .../[source_name]/thumbnail/route.ts | 2 +- src/app/api/manager/streams/[id]/route.ts | 32 +-- src/app/api/manager/streams/route.ts | 2 +- src/app/api/syshealth/route.ts | 16 +- src/app/html_input/page.tsx | 10 + src/app/production/[id]/page.tsx | 170 ++++++++++--- src/components/addInput/AddInput.tsx | 30 +++ src/components/addSource/AddSource.tsx | 24 -- src/components/button/Button.tsx | 2 +- src/components/dragElement/DragItem.tsx | 51 ++-- src/components/filter/FilterDropdown.tsx | 137 ++++++++++- src/components/filter/FilterOptions.tsx | 84 ++++++- src/components/footer/Footer.tsx | 8 +- src/components/inventory/EditViewContext.tsx | 8 +- src/components/inventory/Inventory.tsx | 44 ++-- .../inventory/editView/EditView.tsx | 12 +- .../inventory/editView/GeneralSettings.tsx | 9 + .../inventory/editView/UpdateButtons.tsx | 21 +- src/components/layout/DefaultLayout.tsx | 2 +- src/components/modal/AddSourceModal.tsx | 2 +- .../ConfigureOutputModal.tsx | 167 +++++++++++-- .../modal/configureOutputModal/Input.tsx | 10 +- .../MultiviewSettings.tsx | 29 ++- src/components/multiview/Multiview.tsx | 2 +- src/components/select/Select.tsx | 27 +++ src/components/sourceCard/SourceCard.tsx | 121 +++++++--- src/components/sourceCard/SourceThumbnail.tsx | 58 +++-- src/components/sourceCards/SourceCards.tsx | 139 ++++------- .../sourceListItem/SourceListItem.tsx | 8 +- .../startProduction/StartProductionButton.tsx | 4 +- src/constants.ts | 8 +- src/hooks/controlPanels.ts | 2 +- src/hooks/items/addSetupItem.ts | 8 +- src/hooks/multiviews.ts | 27 ++- src/hooks/pipelines.ts | 2 +- src/hooks/productions.ts | 3 +- src/hooks/sources/useSetSourceToPurge.tsx | 44 ++++ src/hooks/sources/useSources.tsx | 5 +- src/hooks/streams.ts | 36 ++- src/hooks/useDragableItems.ts | 83 ++++--- src/i18n/locales/en.ts | 19 +- src/i18n/locales/sv.ts | 19 +- src/interfaces/Source.ts | 6 +- src/interfaces/pipeline.ts | 4 +- src/middleware.ts | 2 +- src/utils/checkApiConnections.ts | 2 +- src/utils/pipeline.ts | 2 +- types/{agile-live.d.ts => ateliere-live.d.ts} | 0 update_gui_version.sh | 2 +- 88 files changed, 2009 insertions(+), 871 deletions(-) create mode 100644 .github/workflows/dispatch.yml create mode 100644 .github/workflows/release.yml create mode 100644 RELEASE.md delete mode 100644 src/api/agileLive/pipelines/multiviews/multiviews.ts delete mode 100644 src/api/agileLive/utils/authheader.ts rename src/api/{agileLive => ateliereLive}/controlconnections.ts (82%) rename src/api/{agileLive => ateliereLive}/controlpanels.ts (71%) rename src/api/{agileLive => ateliereLive}/ingest.ts (78%) create mode 100644 src/api/ateliereLive/pipelines/multiviews/multiviews.ts rename src/api/{agileLive => ateliereLive}/pipelines/outputs/outputs.ts (84%) rename src/api/{agileLive => ateliereLive}/pipelines/pipelines.ts (95%) rename src/api/{agileLive => ateliereLive}/pipelines/streams/streams.ts (77%) rename src/api/{agileLive => ateliereLive}/streams.ts (82%) create mode 100644 src/api/ateliereLive/utils/authheader.ts rename src/api/{agileLive => ateliereLive}/utils/fwConfigPorts.test.data.ts (100%) rename src/api/{agileLive => ateliereLive}/utils/fwConfigPorts.test.ts (100%) rename src/api/{agileLive => ateliereLive}/utils/fwConfigPorts.ts (100%) rename src/api/{agileLive => ateliereLive}/utils/multiview.ts (100%) rename src/api/{agileLive => ateliereLive}/utils/pipeline.ts (57%) create mode 100644 src/api/ateliereLive/websocket.ts create mode 100644 src/app/api/manager/inventory/[_id]/route.ts create mode 100644 src/app/html_input/page.tsx create mode 100644 src/components/addInput/AddInput.tsx delete mode 100644 src/components/addSource/AddSource.tsx create mode 100644 src/components/select/Select.tsx create mode 100644 src/hooks/sources/useSetSourceToPurge.tsx rename types/{agile-live.d.ts => ateliere-live.d.ts} (100%) diff --git a/.env.sample b/.env.sample index 8f85e45..da9f726 100644 --- a/.env.sample +++ b/.env.sample @@ -1,10 +1,10 @@ # MongoDB -MONGODB_URI=${MONGODB_URI:-mongodb://api:password@localhost:27017/agile-live-gui} +MONGODB_URI=${MONGODB_URI:-mongodb://api:password@localhost:27017/live-gui} -# AgileLive System Controlleer -AGILE_URL=${AGILE_URL:-https://localhost:8080} -AGILE_CREDENTIALS=${AGILE_CREDENTIALS:-admin:admin} -# This ENV variable disables SSL Verification, the above AGILE_URL doesn't have a proper certificate +# Ateliere Live System Controlleer +LIVE_URL=${LIVE_URL:-https://localhost:8080} +LIVE_CREDENTIALS=${LIVE_CREDENTIALS:-admin:admin} +# This ENV variable disables SSL Verification, use if the above LIVE_URL doesn't have a proper certificate NODE_TLS_REJECT_UNAUTHORIZED=${NODE_TLS_REJECT_UNAUTHORIZED:-1} # NextAuth @@ -14,3 +14,6 @@ BCRYPT_SALT_ROUNDS=${BCRYPT_SALT_ROUNDS:-10} # i18n UI_LANG=${UI_LANG:-en} + +# Mediaplayer - path on the system controller +MEDIAPLAYER_PLACEHOLDER=/media/media_placeholder.mp4 \ No newline at end of file diff --git a/.github/workflows/dispatch.yml b/.github/workflows/dispatch.yml new file mode 100644 index 0000000..276cbb3 --- /dev/null +++ b/.github/workflows/dispatch.yml @@ -0,0 +1,59 @@ +name: Trigger build of Docker image + +on: workflow_dispatch + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: Build and push Docker image + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: type=sha + + - name: Extract GUI version + id: gui + env: + TAGS: ${{ steps.meta.outputs.tags }} + run: echo "::set-output name=version::${TAGS##*:}" + + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + build-args: | + GUI_VERSION=${{ steps.gui.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ea884a9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Build and push Docker image when a release is published + +on: + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: Build and push Docker image + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Extract GUI version + id: gui + env: + TAGS: ${{ steps.meta.outputs.tags }} + run: echo "::set-output name=version::${TAGS##*:}" + + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + build-args: | + GUI_VERSION=${{ steps.gui.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 diff --git a/Dockerfile b/Dockerfile index 07dedc9..910bf08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN \ # Rebuild the source code only when needed FROM base AS builder +ARG GUI_VERSION=dev WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -25,17 +26,18 @@ COPY . . # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. -ENV NEXT_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 +RUN echo ${GUI_VERSION} > /app/gui-version.txt RUN npm run build # Production image, copy all the files and run next FROM base AS runner WORKDIR /app -ENV NODE_ENV production +ENV NODE_ENV=production # Uncomment the following line in case you want to disable telemetry during runtime. -ENV NEXT_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs @@ -46,13 +48,11 @@ COPY --from=builder /app/public ./public # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -COPY gui-version.txt ./ - +COPY --from=builder /app/gui-version.txt ./ USER nextjs EXPOSE 3000 -ENV PORT 3000 +ENV PORT=3000 CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md index 335c3e3..ee23668 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,12 @@ Start mongodb docker container using `docker-compose up`. It will initialize the If you want to run the GUI and mongodb docker containers add this to the `docker-compose.yml` file: ``` - agileui: + liveui: build: . environment: - MONGODB_URI: mongodb://api:@host.docker.internal:27017/agile-live-gui - AGILE_URL: https://:8080 - AGILE_CREDENTIALS: : + MONGODB_URI: mongodb://api:@host.docker.internal:27017/live-gui + LIVE_URL: https://:8080 + LIVE_CREDENTIALS: : NODE_TLS_REJECT_UNAUTHORIZED: 0 NEXTAUTH_SECRET: NEXTAUTH_URL: http://localhost:3000 @@ -34,10 +34,10 @@ If you want to run the GUI and mongodb docker containers add this to the `docker Then copy the `.env.sample` file and name it `.env`, it will contain env variables: -- `MONGODB_URI` - The mongodb connection string including credentials eg. `mongodb://user123:pass123@127.0.0.1:27017/agile-live-gui` +- `MONGODB_URI` - The mongodb connection string including credentials eg. `mongodb://user123:pass123@127.0.0.1:27017/live-gui` -- `AGILE_URL` - The URL to the Agile-live system controller REST API -- `AGILE_CREDENTIALS` - Credentials for the Agile-live system controller REST API +- `LIVE_URL` - The URL to the Ateliere Live system controller REST API +- `LIVE_CREDENTIALS` - Credentials for the Ateliere Live system controller REST API - `NEXTAUTH_SECRET` - The secret used to encrypt the JWT Token - `NEXTAUTH_URL` - The base url for the service, eg. `http://localhost:3000`, used internally by NextAuth. @@ -64,7 +64,7 @@ Run following to run application in development environment: ### External Documentation -https://docs.agilecontent.com/docs/acl/reference/3-0-0/rest_api/ +https://help.ateliere.com/live/docs/reference/7-0-0/rest_api/ ### Contributing diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..ef69de6 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,23 @@ +To build and publish a release of the official Docker image, in this example patch version `v1.0.1`: + +1. Create a tag with name `v1.0.1` + +``` +% git tag v1.0.1 +``` + +2. Push the tag to GitHub remote + +``` +% git push --tags +``` + +3. Open browser and navigate to the repository on GitHub. Go to the Releases section by clicking on the Releases header on the right side of the page. + +4. Click on Create a new release and choose the tag `v1.0.1` that you created. + +5. Describe the release by adding release notes in the markdown input field. + +6. Click on Publish release. + +This will trigger a GitHub action that will build Docker image and tag it with `v1.0.1` and push to container registry. diff --git a/docs/production_workflow.md b/docs/production_workflow.md index 4bddda4..f69793e 100644 --- a/docs/production_workflow.md +++ b/docs/production_workflow.md @@ -2,7 +2,7 @@ ## Terminology -- System referes to the agile live backend +- System refers to the ateliere live backend - Manager refers to this application. ## Assumptions diff --git a/package-lock.json b/package-lock.json index 7078d75..26159b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "agile-live-gui", + "name": "ateliere-live-gui", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "agile-live-gui", + "name": "ateliere-live-gui", "version": "1.0.0", "license": "UNLICENSED", "dependencies": { @@ -19,6 +19,7 @@ "@sinclair/typebox": "^0.25.24", "@tabler/icons": "^2.22.0", "@tabler/icons-react": "^2.20.0", + "@types/ws": "^8.5.12", "bcrypt": "^5.1.0", "cron": "^2.3.1", "date-fns": "^2.30.0", @@ -35,7 +36,8 @@ "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "tailwind-merge": "^1.13.2", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.18.0" }, "devDependencies": { "@commitlint/cli": "^17.4.2", @@ -851,13 +853,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "17.6.3", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-17.6.3.tgz", - "integrity": "sha512-LQbNdnPbxrpbcrVKR5yf51SvquqktpyZJwqXx3lUMF6+nT9PHB8xn3wLy8pi2EQv5Zwba484JnUwDE1ygVYNQA==", + "version": "17.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-17.8.1.tgz", + "integrity": "sha512-UshMi4Ltb4ZlNn4F7WtSEugFDZmctzFpmbqvpyxD3la510J+PLcnyhf9chs7EryaRFJMdAKwsEKfNK0jL/QM4g==", "dev": true, "dependencies": { - "@commitlint/types": "^17.4.4", - "semver": "7.5.0" + "@commitlint/types": "^17.8.1", + "semver": "7.5.4" }, "engines": { "node": ">=v14" @@ -997,9 +999,9 @@ } }, "node_modules/@commitlint/types": { - "version": "17.4.4", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-17.4.4.tgz", - "integrity": "sha512-amRN8tRLYOsxRr6mTnGGGvB5EmW/4DDjLMgiwK3CCVEmN6Sr/6xePGEpWaspKkckILuUORCwe6VfDBw6uj4axQ==", + "version": "17.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-17.8.1.tgz", + "integrity": "sha512-PXDQXkAmiMEG162Bqdh9ChML/GJZo6vU+7F03ALKDK8zYc6SuAr47LjG7hGYRqUOz+WK0dU7bQ0xzuqFMdxzeQ==", "dev": true, "dependencies": { "chalk": "^4.1.0" @@ -2512,6 +2514,14 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -6043,10 +6053,22 @@ "node": ">= 0.4" } }, - "node_modules/ip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", - "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" }, "node_modules/ipaddr.js": { "version": "1.9.1", @@ -7210,6 +7232,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -7625,12 +7652,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -9645,9 +9672,9 @@ "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" }, "node_modules/semver": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz", - "integrity": "sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -9827,15 +9854,15 @@ } }, "node_modules/socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dependencies": { - "ip": "^2.0.0", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 10.13.0", + "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, @@ -11216,6 +11243,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index f3a8e12..7b3597c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "name": "agile-live-gui", + "name": "ateliere-live-gui", "version": "1.0.0", "description": "", "scripts": { @@ -10,12 +10,12 @@ "pretty:format": "prettier --write .", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "next lint", - "dev": "./update_gui_version.sh && next dev", + "dev": "next dev", "build": "next build", "start": "next start", "version:rc": "npm version prerelease --preid=rc", "postversion": "git push && git push --tags", - "generate-types:agile-live": "bash -c 'source .env && curl -O -u $AGILE_CREDENTIALS -k \"$AGILE_URL/static/swagger_docs/swagger.json\"' && swagger-typescript-api -p swagger.json -o ./types -n agile-live.d.ts --no-client" + "generate-types:ateliere-live": "bash -c 'source .env && curl -O -u $LIVE_CREDENTIALS -k \"$LIVE_URL/static/swagger_docs/swagger.json\"' && swagger-typescript-api -p swagger.json -o ./types -n ateliere-live.d.ts --no-client" }, "license": "UNLICENSED", "engines": { @@ -32,6 +32,7 @@ "@sinclair/typebox": "^0.25.24", "@tabler/icons": "^2.22.0", "@tabler/icons-react": "^2.20.0", + "@types/ws": "^8.5.12", "bcrypt": "^5.1.0", "cron": "^2.3.1", "date-fns": "^2.30.0", @@ -48,7 +49,8 @@ "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "tailwind-merge": "^1.13.2", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.18.0" }, "devDependencies": { "@commitlint/cli": "^17.4.2", diff --git a/scripts/mongo-init.js b/scripts/mongo-init.js index d3f64b1..7e38304 100644 --- a/scripts/mongo-init.js +++ b/scripts/mongo-init.js @@ -1,8 +1,8 @@ /* eslint-disable no-undef */ -productionsDb = db.getSiblingDB('agile-live-gui'); +productionsDb = db.getSiblingDB('live-gui'); productionsDb.createUser({ user: 'api', pwd: 'password', - roles: [{ role: 'readWrite', db: 'agile-live-gui' }] + roles: [{ role: 'readWrite', db: 'live-gui' }] }); diff --git a/scripts/mongo-mock.js b/scripts/mongo-mock.js index c34611d..8ae3799 100644 --- a/scripts/mongo-mock.js +++ b/scripts/mongo-mock.js @@ -1,5 +1,5 @@ /* eslint-disable no-undef */ -const agileDb = db.getSiblingDB('agile-live-gui'); +const liveDb = db.getSiblingDB('live-gui'); function createSource( name, @@ -116,31 +116,31 @@ const sources = [ ) ]; -agileDb.inventory.insertMany(sources); +liveDb.inventory.insertMany(sources); -agileDb.users.insertOne({ +liveDb.users.insertOne({ username: 'admin' }); -agileDb.productions.insertOne({ +liveDb.productions.insertOne({ name: 'stockholm_podcast', sources, selectedPresetRef: undefined }); -agileDb.productions.insertOne({ +liveDb.productions.insertOne({ name: 'umeå_podcast', sources: [sources[6], sources[7]], selectedPresetRef: undefined }); -agileDb.productions.insertOne({ +liveDb.productions.insertOne({ name: 'Svenska Nyheter', sources: [sources[0], sources[1], sources[2], sources[3]], selectedPresetRef: undefined }); -agileDb.productions.insertOne({ +liveDb.productions.insertOne({ name: 'Morgon Studion', sources: [sources[0], sources[3]], selectedPresetRef: undefined diff --git a/src/api/agileLive/pipelines/multiviews/multiviews.ts b/src/api/agileLive/pipelines/multiviews/multiviews.ts deleted file mode 100644 index d710cc4..0000000 --- a/src/api/agileLive/pipelines/multiviews/multiviews.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { - ResourcesPipelineMultiviewResponse, - ResourcesView -} from '../../../../../types/agile-live'; -import { SourceReference } from '../../../../interfaces/Source'; -import { getAuthorizationHeader } from '../../utils/authheader'; -import { createMultiview } from '../../utils/multiview'; -import { getSourcesByIds } from '../../../manager/sources'; -import { Log } from '../../../logger'; -import { ProductionSettings } from '../../../../interfaces/production'; -import { AGILE_BASE_API_PATH } from '../../../../constants'; - -export async function getMultiviewsForPipeline( - pipelineUUID: string -): Promise { - const response = await fetch( - new URL( - AGILE_BASE_API_PATH + `/pipelines/${pipelineUUID}/multiviews?expand=true`, - process.env.AGILE_URL - ), - { - headers: { - authorization: getAuthorizationHeader() - }, - next: { - revalidate: 0 - } - } - ); - if (response.ok) { - return await response.json(); - } - throw await response.text(); -} - -export async function createMultiviewForPipeline( - productionSettings: ProductionSettings, - sourceRefs: SourceReference[] -): Promise { - // const multiviewPresets = await getMultiviewPresets(); - const multiviewIndex = productionSettings.pipelines.find( - (p) => p.multiview?.for_pipeline_idx !== undefined - )?.multiview?.for_pipeline_idx; - if (multiviewIndex === undefined) { - Log().error(`Did not find a specified pipeline in multiview settings`); - throw `Did not find a specified pipeline in multiview settings`; - } - if (!productionSettings.pipelines[multiviewIndex].multiview) { - Log().error( - `Did not find any multiview settings in pipeline settings for: ${productionSettings.pipelines[multiviewIndex]}` - ); - throw `Did not find any multiview settings in pipeline settings for: ${productionSettings.pipelines[multiviewIndex]}`; - } - const pipelineUUID = - productionSettings.pipelines[multiviewIndex].pipeline_id!; - const sources = await getSourcesByIds( - sourceRefs.map((ref) => ref._id.toString()) - ); - const sourceRefsWithLabels = sourceRefs.map((ref) => { - if (!ref.label) { - const source = sources.find( - (source) => source._id.toString() === ref._id.toString() - ); - ref.label = source?.name || ''; - } - return ref; - }); - Log().info( - `Creating a multiview for pipeline '${pipelineUUID}' from preset '${productionSettings.pipelines[multiviewIndex].multiview?.name}'` - ); - - const multiview = createMultiview( - sourceRefsWithLabels, - productionSettings.pipelines[multiviewIndex].multiview - ); - - let payload = {}; - - if (multiview.output.srt_mode === 'listener') { - payload = { - ...multiview, - output: { - format: multiview.output.format, - frame_rate_d: multiview.output.frame_rate_d, - frame_rate_n: multiview.output.frame_rate_n, - local_ip: multiview.output.local_ip, - local_port: multiview.output.local_port, - srt_mode: multiview.output.srt_mode, - srt_latency_ms: multiview.output.srt_latency_ms, - srt_passphrase: multiview.output.srt_passphrase, - video_format: multiview.output.video_format, - video_kilobit_rate: multiview.output.video_kilobit_rate - } - }; - } - if (multiview.output.srt_mode === 'caller') { - payload = { - ...multiview, - output: { - format: multiview.output.format, - frame_rate_d: multiview.output.frame_rate_d, - frame_rate_n: multiview.output.frame_rate_n, - local_ip: '0.0.0.0', - local_port: 0, - remote_ip: multiview.output.remote_ip, - remote_port: multiview.output.remote_port, - srt_mode: multiview.output.srt_mode, - srt_latency_ms: multiview.output.srt_latency_ms, - srt_passphrase: multiview.output.srt_passphrase, - video_format: multiview.output.video_format, - video_kilobit_rate: multiview.output.video_kilobit_rate - } - }; - } - - const response = await fetch( - new URL( - AGILE_BASE_API_PATH + `/pipelines/${pipelineUUID}/multiviews`, - process.env.AGILE_URL - ), - { - method: 'POST', - headers: { - authorization: getAuthorizationHeader() - }, - next: { - revalidate: 0 - }, - body: JSON.stringify(payload) - } - ); - - if (response.ok) { - return await response.json(); - } - throw await response.text(); -} - -export async function deleteMultiviewFromPipeline( - pipelineUUID: string, - multiviewId: number -): Promise { - const response = await fetch( - new URL( - AGILE_BASE_API_PATH + - `/pipelines/${pipelineUUID}/multiviews/${multiviewId}`, - process.env.AGILE_URL - ), - { - method: 'DELETE', - headers: { - authorization: getAuthorizationHeader() - }, - next: { - revalidate: 0 - } - } - ); - - if (response.ok) { - return; - } - throw await response.text(); -} - -export async function deleteAllMultiviewsFromPipeline( - pipelineUUID: string -): Promise { - const multiviews = await getMultiviewsForPipeline(pipelineUUID); - - await Promise.allSettled( - multiviews.map((multiview) => - deleteMultiviewFromPipeline(pipelineUUID, multiview.id!) - ) - ); -} - -export async function updateMultiviewForPipeline( - pipelineUUID: string, - multiviewId: number, - views: ResourcesView[] -): Promise { - Log().info( - `Updating multiview ${multiviewId} for pipeline '${pipelineUUID}'` - ); - const response = await fetch( - new URL( - AGILE_BASE_API_PATH + - `/pipelines/${pipelineUUID}/multiviews/${multiviewId}`, - process.env.AGILE_URL - ), - { - method: 'PUT', - headers: { - authorization: getAuthorizationHeader() - }, - next: { - revalidate: 0 - }, - body: JSON.stringify({ views: views }) - } - ); - if (response.ok) { - return await response.json(); - } - throw await response.text(); -} diff --git a/src/api/agileLive/utils/authheader.ts b/src/api/agileLive/utils/authheader.ts deleted file mode 100644 index 51607d5..0000000 --- a/src/api/agileLive/utils/authheader.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function getAuthorizationHeader() { - if (process.env.AGILE_CREDENTIALS) { - return `Basic ${Buffer.from(process.env.AGILE_CREDENTIALS).toString( - 'base64' - )}`; - } - return ''; -} diff --git a/src/api/agileLive/controlconnections.ts b/src/api/ateliereLive/controlconnections.ts similarity index 82% rename from src/api/agileLive/controlconnections.ts rename to src/api/ateliereLive/controlconnections.ts index cf217f6..34e6d2b 100644 --- a/src/api/agileLive/controlconnections.ts +++ b/src/api/ateliereLive/controlconnections.ts @@ -1,5 +1,5 @@ -import { ResourcesCreateControlConnectionResponse } from '../../../types/agile-live'; -import { AGILE_BASE_API_PATH } from '../../constants'; +import { ResourcesCreateControlConnectionResponse } from '../../../types/ateliere-live'; +import { LIVE_BASE_API_PATH } from '../../constants'; import { getAuthorizationHeader } from './utils/authheader'; export async function connectSenderAndReceiver( @@ -9,7 +9,7 @@ export async function connectSenderAndReceiver( port: number ): Promise { const response = await fetch( - new URL(AGILE_BASE_API_PATH + `/controlconnections`, process.env.AGILE_URL), + new URL(LIVE_BASE_API_PATH + `/controlconnections`, process.env.LIVE_URL), { method: 'POST', headers: { @@ -35,8 +35,8 @@ export async function connectSenderAndReceiver( export async function disconnectReceiver(receiverId: string): Promise { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/controlconnections/${receiverId}`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/controlconnections/${receiverId}`, + process.env.LIVE_URL ), { method: 'DELETE', diff --git a/src/api/agileLive/controlpanels.ts b/src/api/ateliereLive/controlpanels.ts similarity index 71% rename from src/api/agileLive/controlpanels.ts rename to src/api/ateliereLive/controlpanels.ts index 50dfbfd..4a0b683 100644 --- a/src/api/agileLive/controlpanels.ts +++ b/src/api/ateliereLive/controlpanels.ts @@ -1,5 +1,5 @@ -import { ResourcesControlPanelResponse } from '../../../types/agile-live'; -import { AGILE_BASE_API_PATH } from '../../constants'; +import { ResourcesControlPanelResponse } from '../../../types/ateliere-live'; +import { LIVE_BASE_API_PATH } from '../../constants'; import { getAuthorizationHeader } from './utils/authheader'; export async function getControlPanels(): Promise< @@ -7,8 +7,8 @@ export async function getControlPanels(): Promise< > { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/controlpanels?expand=true`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/controlpanels?expand=true`, + process.env.LIVE_URL ), { method: 'GET', diff --git a/src/api/agileLive/ingest.ts b/src/api/ateliereLive/ingest.ts similarity index 78% rename from src/api/agileLive/ingest.ts rename to src/api/ateliereLive/ingest.ts index dc3b101..0c7b521 100644 --- a/src/api/agileLive/ingest.ts +++ b/src/api/ateliereLive/ingest.ts @@ -2,8 +2,8 @@ import { ResourcesCompactIngestResponse, ResourcesIngestResponse, ResourcesThumbnailResponse -} from '../../../types/agile-live'; -import { AGILE_BASE_API_PATH } from '../../constants'; +} from '../../../types/ateliere-live'; +import { LIVE_BASE_API_PATH } from '../../constants'; import { getAuthorizationHeader } from './utils/authheader'; // TODO: create proper cache... @@ -58,10 +58,7 @@ export async function getSourceIdFromSourceName( export async function getIngests(): Promise { const response = await fetch( - new URL( - AGILE_BASE_API_PATH + `/ingests?expand=true`, - process.env.AGILE_URL - ), + new URL(LIVE_BASE_API_PATH + `/ingests?expand=true`, process.env.LIVE_URL), { headers: { authorization: getAuthorizationHeader() @@ -82,8 +79,8 @@ export async function getIngest( ): Promise { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/ingests/${uuid}?expand=true`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/ingests/${uuid}?expand=true`, + process.env.LIVE_URL ), { headers: { @@ -104,9 +101,9 @@ export async function getSourceThumbnail( ) { const response = await fetch( new URL( - AGILE_BASE_API_PATH + + LIVE_BASE_API_PATH + `/ingests/${ingestUuid}/sources/${sourceId}/thumbnail`, - process.env.AGILE_URL + process.env.LIVE_URL ), { method: 'POST', @@ -123,7 +120,29 @@ export async function getSourceThumbnail( ); if (response.ok) { const json = (await response.json()) as ResourcesThumbnailResponse; - return json?.data; + return json.data; } throw await response.json(); } + +export async function deleteSrtSource(ingestUuid: string, sourceId: number) { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + `/ingests/${ingestUuid}/sources/${sourceId}`, + process.env.LIVE_URL + ), + { + method: 'DELETE', + headers: { + authorization: getAuthorizationHeader() + }, + next: { + revalidate: 0 + } + } + ); + if (response.ok) { + return response.status; + } + throw await response.text(); +} diff --git a/src/api/ateliereLive/pipelines/multiviews/multiviews.ts b/src/api/ateliereLive/pipelines/multiviews/multiviews.ts new file mode 100644 index 0000000..12926b3 --- /dev/null +++ b/src/api/ateliereLive/pipelines/multiviews/multiviews.ts @@ -0,0 +1,226 @@ +import { + ResourcesPipelineMultiviewResponse, + ResourcesView +} from '../../../../../types/ateliere-live'; +import { SourceReference } from '../../../../interfaces/Source'; +import { getAuthorizationHeader } from '../../utils/authheader'; +import { createMultiview } from '../../utils/multiview'; +import { getSourcesByIds } from '../../../manager/sources'; +import { Log } from '../../../logger'; +import { ProductionSettings } from '../../../../interfaces/production'; +import { MultiviewSettings } from '../../../../interfaces/multiview'; +import { LIVE_BASE_API_PATH } from '../../../../constants'; + +export async function getMultiviewsForPipeline( + pipelineUUID: string +): Promise { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + `/pipelines/${pipelineUUID}/multiviews?expand=true`, + process.env.LIVE_URL + ), + { + headers: { + authorization: getAuthorizationHeader() + }, + next: { + revalidate: 0 + } + } + ); + if (response.ok) { + return await response.json(); + } + throw await response.text(); +} + +export async function createMultiviewForPipeline( + productionSettings: ProductionSettings, + sourceRefs: SourceReference[] +): Promise { + // TODO Check if this can be cleaned out. This is an old code and dont know the purpose of it, therefor I dont want to remove it yet. + // const multiviewPresets = await getMultiviewPresets(); + + const pipeline = productionSettings.pipelines.find((p) => + p.multiviews ? p.multiviews?.length > 0 : undefined + ); + const multiviewIndexArray = pipeline?.multiviews + ? pipeline.multiviews.map((p) => p.for_pipeline_idx) + : undefined; + + const multiviewIndex = multiviewIndexArray?.find((p) => p !== undefined); + + if (multiviewIndex === undefined) { + Log().error(`Did not find a specified pipeline in multiview settings`); + throw `Did not find a specified pipeline in multiview settings`; + } + if ( + !productionSettings.pipelines[multiviewIndex].multiviews || + productionSettings.pipelines[multiviewIndex].multiviews?.length === 0 + ) { + Log().error( + `Did not find any multiview settings in pipeline settings for: ${productionSettings.pipelines[multiviewIndex]}` + ); + throw `Did not find any multiview settings in pipeline settings for: ${productionSettings.pipelines[multiviewIndex]}`; + } + const pipelineUUID = + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + productionSettings.pipelines[multiviewIndex].pipeline_id!; + const sources = await getSourcesByIds( + sourceRefs.map((ref) => ref._id.toString()) + ); + const sourceRefsWithLabels = sourceRefs.map((ref) => { + if (!ref.label) { + const source = sources.find( + (source) => source._id.toString() === ref._id.toString() + ); + ref.label = source?.name || ''; + } + return ref; + }); + Log().info(`Creating a multiview for pipeline '${pipelineUUID}' from preset`); + + const multiviewsSettings: MultiviewSettings[] = + productionSettings.pipelines[multiviewIndex].multiviews ?? []; + + const createEachMultiviewer = multiviewsSettings.map( + async (singleMultiviewSettings) => { + const multiview = createMultiview( + sourceRefsWithLabels, + singleMultiviewSettings + ); + + let payload = {}; + + if (multiview.output.srt_mode === 'listener') { + payload = { + ...multiview, + output: { + format: multiview.output.format, + frame_rate_d: multiview.output.frame_rate_d, + frame_rate_n: multiview.output.frame_rate_n, + local_ip: multiview.output.local_ip, + local_port: multiview.output.local_port, + srt_mode: multiview.output.srt_mode, + srt_latency_ms: multiview.output.srt_latency_ms, + srt_passphrase: multiview.output.srt_passphrase, + video_format: multiview.output.video_format, + video_kilobit_rate: multiview.output.video_kilobit_rate + } + }; + } + if (multiview.output.srt_mode === 'caller') { + payload = { + ...multiview, + output: { + format: multiview.output.format, + frame_rate_d: multiview.output.frame_rate_d, + frame_rate_n: multiview.output.frame_rate_n, + local_ip: '0.0.0.0', + local_port: 0, + remote_ip: multiview.output.remote_ip, + remote_port: multiview.output.remote_port, + srt_mode: multiview.output.srt_mode, + srt_latency_ms: multiview.output.srt_latency_ms, + srt_passphrase: multiview.output.srt_passphrase, + video_format: multiview.output.video_format, + video_kilobit_rate: multiview.output.video_kilobit_rate + } + }; + } + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + `/pipelines/${pipelineUUID}/multiviews`, + process.env.LIVE_URL + ), + { + method: 'POST', + headers: { + authorization: getAuthorizationHeader() + }, + next: { + revalidate: 0 + }, + body: JSON.stringify(payload) + } + ); + + if (response.ok) { + return await response.json(); + } + throw await response.text(); + } + ); + + return Promise.all(createEachMultiviewer); +} + +export async function deleteMultiviewFromPipeline( + pipelineUUID: string, + multiviewId: number +): Promise { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + + `/pipelines/${pipelineUUID}/multiviews/${multiviewId}`, + process.env.LIVE_URL + ), + { + method: 'DELETE', + headers: { + authorization: getAuthorizationHeader() + }, + next: { + revalidate: 0 + } + } + ); + + if (response.ok) { + return; + } + throw await response.text(); +} + +export async function deleteAllMultiviewsFromPipeline( + pipelineUUID: string +): Promise { + const multiviews = await getMultiviewsForPipeline(pipelineUUID); + + await Promise.allSettled( + multiviews.map((multiview) => + deleteMultiviewFromPipeline(pipelineUUID, multiview.id!) + ) + ); +} + +export async function updateMultiviewForPipeline( + pipelineUUID: string, + multiviewId: number, + views: ResourcesView[] +): Promise { + Log().info( + `Updating multiview ${multiviewId} for pipeline '${pipelineUUID}'` + ); + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + + `/pipelines/${pipelineUUID}/multiviews/${multiviewId}`, + process.env.LIVE_URL + ), + { + method: 'PUT', + headers: { + authorization: getAuthorizationHeader() + }, + next: { + revalidate: 0 + }, + body: JSON.stringify({ views: views }) + } + ); + if (response.ok) { + return await response.json(); + } + throw await response.text(); +} diff --git a/src/api/agileLive/pipelines/outputs/outputs.ts b/src/api/ateliereLive/pipelines/outputs/outputs.ts similarity index 84% rename from src/api/agileLive/pipelines/outputs/outputs.ts rename to src/api/ateliereLive/pipelines/outputs/outputs.ts index 3387bcc..2a2fca7 100644 --- a/src/api/agileLive/pipelines/outputs/outputs.ts +++ b/src/api/ateliereLive/pipelines/outputs/outputs.ts @@ -1,5 +1,5 @@ -import { ResourcesOutputStatusResponse } from '../../../../../types/agile-live'; -import { AGILE_BASE_API_PATH } from '../../../../constants'; +import { ResourcesOutputStatusResponse } from '../../../../../types/ateliere-live'; +import { LIVE_BASE_API_PATH } from '../../../../constants'; import { PipelineOutputSettings } from '../../../../interfaces/pipeline'; import { getAuthorizationHeader } from '../../utils/authheader'; @@ -8,8 +8,8 @@ export async function getPipelineOutputs( ): Promise { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/pipelines/${pipelineId}/outputs?expand=true`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/pipelines/${pipelineId}/outputs?expand=true`, + process.env.LIVE_URL ), { method: 'GET', @@ -33,8 +33,8 @@ export async function stopAllOutputStreamsByUuid( ) { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/pipelines/${pipeId}/outputs/${outputId}/streams`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/pipelines/${pipeId}/outputs/${outputId}/streams`, + process.env.LIVE_URL ), { method: 'DELETE', @@ -59,9 +59,9 @@ export async function stopSingleOutputStream( ) { const response = await fetch( new URL( - AGILE_BASE_API_PATH + + LIVE_BASE_API_PATH + `/pipelines/${pipeId}/outputs/${outputId}/streams/${outputStreamId}`, - process.env.AGILE_URL + process.env.LIVE_URL ), { method: 'DELETE', @@ -87,9 +87,9 @@ export async function startPipelineStream( const requests = streamSettings.map((streamSetting) => { return fetch( new URL( - AGILE_BASE_API_PATH + + LIVE_BASE_API_PATH + `/pipelines/${pipelineId}/outputs/${outputId}/streams`, - process.env.AGILE_URL + process.env.LIVE_URL ), { method: 'POST', diff --git a/src/api/agileLive/pipelines/pipelines.ts b/src/api/ateliereLive/pipelines/pipelines.ts similarity index 95% rename from src/api/agileLive/pipelines/pipelines.ts rename to src/api/ateliereLive/pipelines/pipelines.ts index a2c43c5..32fc309 100644 --- a/src/api/agileLive/pipelines/pipelines.ts +++ b/src/api/ateliereLive/pipelines/pipelines.ts @@ -1,7 +1,7 @@ import { ResourcesCompactPipelineResponse, ResourcesPipelineResponse -} from '../../../../types/agile-live'; +} from '../../../../types/ateliere-live'; import { PipelineOutputSettings, PipelineSettings @@ -18,15 +18,15 @@ import { import { getPipelineStreams } from './streams/streams'; import { ControlConnection } from '../../../interfaces/controlConnections'; import { Log } from '../../logger'; -import { AGILE_BASE_API_PATH } from '../../../constants'; +import { LIVE_BASE_API_PATH } from '../../../constants'; export async function getPipeline( uuid: string ): Promise { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/pipelines/${uuid}?expand=true`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/pipelines/${uuid}?expand=true`, + process.env.LIVE_URL ), { method: 'GET', @@ -49,8 +49,8 @@ export async function getPipelineCompact( ): Promise { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/pipelines/${uuid}?expand=false`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/pipelines/${uuid}?expand=false`, + process.env.LIVE_URL ), { method: 'GET', @@ -73,8 +73,8 @@ export async function getPipelines(): Promise< > { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/pipelines?expand=true`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/pipelines?expand=true`, + process.env.LIVE_URL ), { method: 'GET', @@ -102,8 +102,8 @@ export async function getPipelines(): Promise< export async function resetPipeline(pipelineUuid: string): Promise { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/pipelines/${pipelineUuid}/reset`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/pipelines/${pipelineUuid}/reset`, + process.env.LIVE_URL ), { method: 'PUT', diff --git a/src/api/agileLive/pipelines/streams/streams.ts b/src/api/ateliereLive/pipelines/streams/streams.ts similarity index 77% rename from src/api/agileLive/pipelines/streams/streams.ts rename to src/api/ateliereLive/pipelines/streams/streams.ts index 6fb26aa..7cbd58e 100644 --- a/src/api/agileLive/pipelines/streams/streams.ts +++ b/src/api/ateliereLive/pipelines/streams/streams.ts @@ -1,10 +1,11 @@ -import { ResourcesUUIDResponse } from '../../../../../types/agile-live'; -import { AGILE_BASE_API_PATH } from '../../../../constants'; +import { ResourcesUUIDResponse } from '../../../../../types/ateliere-live'; +import { LIVE_BASE_API_PATH } from '../../../../constants'; import { AddSourceResult, SourceToPipelineStream, SourceWithId } from '../../../../interfaces/Source'; +import { MultiviewSettings } from '../../../../interfaces/multiview'; import { PipelineStreamSettings } from '../../../../interfaces/pipeline'; import { Production } from '../../../../interfaces/production'; import { Result } from '../../../../interfaces/result'; @@ -28,8 +29,8 @@ export async function getPipelineStreams( ): Promise { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/pipelines/${pipelineUuid}/streams`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/pipelines/${pipelineUuid}/streams`, + process.env.LIVE_URL ), { method: 'GET', @@ -46,6 +47,7 @@ export async function getPipelineStreams( } throw await response.json(); } + export async function createStream( source: SourceWithId, production: Production, @@ -197,59 +199,77 @@ export async function createStream( ); throw `Missing pipeline_id for: ${production.production_settings.pipelines[0].pipeline_name}`; } - const multiviews = await getMultiviewsForPipeline( + const multiviewsResponse = await getMultiviewsForPipeline( production.production_settings.pipelines[0].pipeline_id ); - const multiview = multiviews.find( - (multiview) => - multiview.id === - production.production_settings.pipelines[0].multiview?.multiview_id - ); - if (!multiview) { + + const multiviews = multiviewsResponse.filter((multiview) => { + const pipeline = production.production_settings.pipelines[0]; + const multiviewArray = pipeline.multiviews; + + if (Array.isArray(multiviewArray)) { + return multiviewArray.some( + (item) => item.multiview_id === multiview.id + ); + } else if (multiviewArray) { + return ( + (multiviewArray as MultiviewSettings).multiview_id === multiview.id + ); + } + + return false; + }); + + if (multiviews.length === 0) { Log().error( `No multiview found for pipeline: ${production.production_settings.pipelines[0].pipeline_id}` ); throw `No multiview found for pipeline: ${production.production_settings.pipelines[0].pipeline_id}`; } - const views = multiview.layout.views; - const viewsForSource = views.filter( - (view) => view.input_slot === input_slot - ); - if (!viewsForSource || viewsForSource.length === 0) { - Log().info( - `No view found for input slot: ${input_slot}. Will not connect source to view` + multiviews.map(async (multiview) => { + const views = multiview.layout.views; + const viewsForSource = views.filter( + (view) => view.input_slot === input_slot + ); + if (!viewsForSource || viewsForSource.length === 0) { + Log().info( + `No view found for input slot: ${input_slot}. Will not connect source to view` + ); + return { + ok: true, + value: { + success: true, + steps: [ + { + step: 'add_stream', + success: true + }, + { + step: 'update_multiview', + success: true + } + ], + streams: sourceToPipelineStreams + } + }; + // TODO Check if this can be cleaned out. This is an old code and dont know the purpose of it, therefor I dont want to remove it yet. + // return sourceToPipelineStreams; + } + const updatedViewsForSource = viewsForSource.map((v) => { + return { ...v, label: source.name }; + }); + + const updatedViews = [ + ...views.filter((view) => view.input_slot !== input_slot), + ...updatedViewsForSource + ]; + + await updateMultiviewForPipeline( + production.production_settings.pipelines[0].pipeline_id!, + multiview.id, + updatedViews ); - return { - ok: true, - value: { - success: true, - steps: [ - { - step: 'add_stream', - success: true - }, - { - step: 'update_multiview', - success: true - } - ], - streams: sourceToPipelineStreams - } - }; - // return sourceToPipelineStreams; - } - const updatedViewsForSource = viewsForSource.map((v) => { - return { ...v, label: source.name }; }); - const updatedViews = [ - ...views.filter((view) => view.input_slot !== input_slot), - ...updatedViewsForSource - ]; - await updateMultiviewForPipeline( - production.production_settings.pipelines[0].pipeline_id!, - multiview.id, - updatedViews - ); } catch (e) { Log().error('Could not update multiview after adding stream'); Log().error(e); @@ -313,8 +333,8 @@ export async function createStream( export async function deleteStream(streamUuid: string) { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/streams/${streamUuid}`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/streams/${streamUuid}`, + process.env.LIVE_URL ), { method: 'DELETE', diff --git a/src/api/agileLive/streams.ts b/src/api/ateliereLive/streams.ts similarity index 82% rename from src/api/agileLive/streams.ts rename to src/api/ateliereLive/streams.ts index 1a5ec5f..ef97cde 100644 --- a/src/api/agileLive/streams.ts +++ b/src/api/ateliereLive/streams.ts @@ -1,4 +1,4 @@ -import { AGILE_BASE_API_PATH } from '../../constants'; +import { LIVE_BASE_API_PATH } from '../../constants'; import { PipelineStreamSettings } from '../../interfaces/pipeline'; import { getAuthorizationHeader } from './utils/authheader'; @@ -9,7 +9,7 @@ export async function connectIngestToPipeline( throw new Error('Error: No pipeline_id!'); } const response = await fetch( - new URL(AGILE_BASE_API_PATH + `/streams`, process.env.AGILE_URL), + new URL(LIVE_BASE_API_PATH + `/streams`, process.env.LIVE_URL), { method: 'POST', headers: { @@ -30,8 +30,8 @@ export async function connectIngestToPipeline( export async function deleteStreamByUuid(streamUuId: string) { const response = await fetch( new URL( - AGILE_BASE_API_PATH + `/streams/${streamUuId}`, - process.env.AGILE_URL + LIVE_BASE_API_PATH + `/streams/${streamUuId}`, + process.env.LIVE_URL ), { method: 'DELETE', diff --git a/src/api/ateliereLive/utils/authheader.ts b/src/api/ateliereLive/utils/authheader.ts new file mode 100644 index 0000000..0f4dc4e --- /dev/null +++ b/src/api/ateliereLive/utils/authheader.ts @@ -0,0 +1,8 @@ +export function getAuthorizationHeader() { + if (process.env.LIVE_CREDENTIALS) { + return `Basic ${Buffer.from(process.env.LIVE_CREDENTIALS).toString( + 'base64' + )}`; + } + return ''; +} diff --git a/src/api/agileLive/utils/fwConfigPorts.test.data.ts b/src/api/ateliereLive/utils/fwConfigPorts.test.data.ts similarity index 100% rename from src/api/agileLive/utils/fwConfigPorts.test.data.ts rename to src/api/ateliereLive/utils/fwConfigPorts.test.data.ts diff --git a/src/api/agileLive/utils/fwConfigPorts.test.ts b/src/api/ateliereLive/utils/fwConfigPorts.test.ts similarity index 100% rename from src/api/agileLive/utils/fwConfigPorts.test.ts rename to src/api/ateliereLive/utils/fwConfigPorts.test.ts diff --git a/src/api/agileLive/utils/fwConfigPorts.ts b/src/api/ateliereLive/utils/fwConfigPorts.ts similarity index 100% rename from src/api/agileLive/utils/fwConfigPorts.ts rename to src/api/ateliereLive/utils/fwConfigPorts.ts diff --git a/src/api/agileLive/utils/multiview.ts b/src/api/ateliereLive/utils/multiview.ts similarity index 100% rename from src/api/agileLive/utils/multiview.ts rename to src/api/ateliereLive/utils/multiview.ts diff --git a/src/api/agileLive/utils/pipeline.ts b/src/api/ateliereLive/utils/pipeline.ts similarity index 57% rename from src/api/agileLive/utils/pipeline.ts rename to src/api/ateliereLive/utils/pipeline.ts index 3d14102..5083ebf 100644 --- a/src/api/agileLive/utils/pipeline.ts +++ b/src/api/ateliereLive/utils/pipeline.ts @@ -1,4 +1,4 @@ -import { ResourcesPipelineResponse } from '../../../../types/agile-live'; +import { ResourcesPipelineResponse } from '../../../../types/ateliere-live'; export function isActive(pipeline: ResourcesPipelineResponse) { return !!pipeline.streams?.length; diff --git a/src/api/ateliereLive/websocket.ts b/src/api/ateliereLive/websocket.ts new file mode 100644 index 0000000..6f51466 --- /dev/null +++ b/src/api/ateliereLive/websocket.ts @@ -0,0 +1,45 @@ +import WebSocket from 'ws'; + +function createWebSocket(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://${process.env.AGILE_WEBSOCKET}`); + ws.on('error', reject); + ws.on('open', () => { + // const send = ws.send.bind(ws); + // ws.send = (message) => { + // console.debug(`[websocket] sending message: ${message}`); + // send(message); + // }; + resolve(ws); + }); + }); +} + +export async function createControlPanelWebSocket() { + const ws = await createWebSocket(); + return { + createHtml: (input: number) => { + ws.send('html reset'); + ws.send(`html create ${input} 1920 1080`); + setTimeout(() => { + ws.send( + `html load ${input} ${process.env.NEXTAUTH_URL}/html_input?input=${input}` + ); + }, 1000); + }, + createMediaplayer: (input: number) => { + ws.send('media reset'); + ws.send(`media create ${input} ${process.env.MEDIAPLAYER_PLACEHOLDER}`); + ws.send(`media play ${input}`); + }, + closeHtml: (input: number) => { + ws.send(`html close ${input}`); + }, + closeMediaplayer: (input: number) => { + ws.send(`media close ${input}`); + }, + close: () => { + ws.close(); + } + }; +} diff --git a/src/api/manager/inventory.ts b/src/api/manager/inventory.ts index e5796ca..7d47e1e 100644 --- a/src/api/manager/inventory.ts +++ b/src/api/manager/inventory.ts @@ -1,6 +1,7 @@ -import { ObjectId } from 'mongodb'; +import { ObjectId, UpdateResult } from 'mongodb'; import { getDatabase } from '../mongoClient/dbClient'; import { Numbers } from '../../interfaces/Source'; +import { Log } from '../logger'; interface IResponse { audio_stream?: { @@ -20,3 +21,21 @@ export async function getAudioMapping(id: ObjectId): Promise { throw `Could not find audio mapping for source: ${id.toString()}`; })) as IResponse; } + +export async function purgeInventorySourceItem( + id: string +): Promise> { + const db = await getDatabase(); + const objectId = new ObjectId(id); + + // Not possible to delete from API so this adds a purge-flag + // to the source + const result = await db + .collection('inventory') + .updateOne({ _id: objectId, status: 'gone' }, { $set: { status: 'purge' } }) + .catch((error) => { + throw `Was not able to set source-id for ${id} to purge: ${error}`; + }); + + return result as UpdateResult; +} diff --git a/src/api/manager/job/syncInventory.ts b/src/api/manager/job/syncInventory.ts index e563567..09e358c 100644 --- a/src/api/manager/job/syncInventory.ts +++ b/src/api/manager/job/syncInventory.ts @@ -1,10 +1,13 @@ -import { ResourcesIngestResponse } from '../../../../types/agile-live'; +import { ResourcesIngestResponse } from '../../../../types/ateliere-live'; import { Source } from '../../../interfaces/Source'; -import { getIngests, getIngest } from '../../agileLive/ingest'; +import { getIngests, getIngest } from '../../ateliereLive/ingest'; import { upsertSource } from '../sources'; import { getDatabase } from '../../mongoClient/dbClient'; import { WithId } from 'mongodb'; +type SourceWithoutLastConnected = Omit; + +// TODO: getSourcesFromAPI should return ResourcesSourceResponse and changed to our model later async function getSourcesFromAPI() { const ingests = await getIngests(); const resolvedIngests = ( @@ -15,44 +18,68 @@ async function getSourcesFromAPI() { result.status === 'fulfilled' ) .map((result) => result.value); - const sources: Source[] = resolvedIngests.flatMap((ingest) => { - return ingest.sources.map( - (source) => - ({ - status: source.active ? 'new' : 'gone', - name: source.name, - type: 'camera', - tags: { - location: 'Unknown' - }, - ingest_name: ingest.name, - ingest_source_name: source.name, - video_stream: { - width: source?.video_stream?.width, - height: source?.video_stream?.height, - frame_rate: - source?.video_stream?.frame_rate_n / - source?.video_stream?.frame_rate_d - }, - audio_stream: { - number_of_channels: source?.audio_stream?.number_of_channels, - sample_rate: source?.audio_stream?.sample_rate - } - } satisfies Source) - ); - }); + const sources: SourceWithoutLastConnected[] = resolvedIngests.flatMap( + (ingest) => { + return ingest.sources.map( + (source) => + ({ + status: source.active ? 'new' : 'gone', + name: source.name, + type: 'camera', + tags: { + location: 'Unknown' + }, + ingest_name: ingest.name, + ingest_source_name: source.name, + ingest_type: source.type, + video_stream: { + width: source?.video_stream?.width, + height: source?.video_stream?.height, + frame_rate: + source?.video_stream?.frame_rate_n / + source?.video_stream?.frame_rate_d + }, + audio_stream: { + number_of_channels: source?.audio_stream?.number_of_channels, + sample_rate: source?.audio_stream?.sample_rate + } + } satisfies SourceWithoutLastConnected) + ); + } + ); return sources; } /** - * Syncs the inventory with the ingests in Agile Live. + * Syncs the inventory with the ingests in Ateliere Live. * - Adds new sources to the inventory with the status 'new' * - Updates the status of sources depending on wheter or not they are still present in the ingests */ export async function runSyncInventory() { const db = await getDatabase(); - const dbInventory = await db.collection('inventory').find().toArray(); const apiSources = await getSourcesFromAPI(); + const dbInventory = await db.collection('inventory').find().toArray(); + + const statusUpdateCheck = ( + inventorySource: WithId, + apiSource: SourceWithoutLastConnected, + lastConnected: Date + ) => { + const databaseStatus = inventorySource.status; + const apiStatus = apiSource.status; + const currentTime = new Date().getTime(); + const lastConnectedTime = new Date(lastConnected).getTime(); + const monthInMilliseconds = 30 * 24 * 60 * 60 * 1000; + const expiryTime = lastConnectedTime + monthInMilliseconds; + + if (databaseStatus === 'purge' && apiStatus === 'gone') { + return databaseStatus; + } else if (apiStatus === 'gone' && currentTime > expiryTime) { + return 'purge'; + } else { + return apiStatus; + } + }; // Update status of all sources in the inventory to the status found in API. // If a source is not found in the API, it is marked as gone. @@ -67,26 +94,33 @@ export async function runSyncInventory() { // If source was not found in response from API, always mark it as gone return { ...inventorySource, status: 'gone' } satisfies WithId; } + const lastConnected = + apiSource.status !== 'gone' ? new Date() : inventorySource.lastConnected; + // Keep all old fields from the inventory source (name, tags, id, audio_stream etc), but update the status return { ...inventorySource, - status: apiSource.status + status: statusUpdateCheck(inventorySource, apiSource, lastConnected), + lastConnected: lastConnected } satisfies WithId; }); // Look for new sources that doesn't already exist in the inventory, // these should all be added to the inventory, status of these are set in getSourcesFromAPI. - const newSourcesToUpsert = apiSources.filter((source) => { - const existingSource = dbInventoryWithCorrectStatus.find( - (inventorySource) => { - return ( - source.ingest_name === inventorySource.ingest_name && - source.ingest_source_name === inventorySource.ingest_source_name - ); - } - ); - return !existingSource; - }); + const newSourcesToUpsert = apiSources + .filter((source) => { + const existingSource = dbInventoryWithCorrectStatus.find( + (inventorySource) => { + return ( + source.ingest_name === inventorySource.ingest_name && + source.ingest_source_name === inventorySource.ingest_source_name && + source.ingest_type === inventorySource.ingest_type + ); + } + ); + return !existingSource; + }) + .map((source) => ({ ...source, lastConnected: new Date() })); const sourcesToUpsert = [ ...newSourcesToUpsert, diff --git a/src/api/manager/job/syncMonitoring.ts b/src/api/manager/job/syncMonitoring.ts index e206459..7619bfd 100644 --- a/src/api/manager/job/syncMonitoring.ts +++ b/src/api/manager/job/syncMonitoring.ts @@ -1,6 +1,9 @@ import { Production } from '../../../interfaces/production'; import { getDatabase } from '../../mongoClient/dbClient'; -import { getPipeline, getPipelines } from '../../agileLive/pipelines/pipelines'; +import { + getPipeline, + getPipelines +} from '../../ateliereLive/pipelines/pipelines'; import { ResourcesControlPanelResponse, ResourcesControlRequestCounter, @@ -10,9 +13,9 @@ import { ResourcesPipelineResponse, ResourcesReceiverNetworkEndpoint, ResourcesSenderNetworkEndpoint -} from '../../../../types/agile-live'; -import { getControlPanels } from '../../agileLive/controlpanels'; -import { getIngest, getIngests } from '../../agileLive/ingest'; +} from '../../../../types/ateliere-live'; +import { getControlPanels } from '../../ateliereLive/controlpanels'; +import { getIngest, getIngests } from '../../ateliereLive/ingest'; import { MonitoringControlPanelStatusResponse, MonitoringOutputStatusResponse, @@ -350,7 +353,7 @@ export async function updatedMonitoringForProduction( } /** - * Syncs the Monitoring DB with all the active productions and compares with data in Agile Live. + * Syncs the Monitoring DB with all the active productions and compares with data in Ateliere Live. */ export async function runSyncMonitoring() { const db = await getDatabase(); diff --git a/src/api/manager/productions.ts b/src/api/manager/productions.ts index e68524a..714d822 100644 --- a/src/api/manager/productions.ts +++ b/src/api/manager/productions.ts @@ -2,6 +2,7 @@ import { Db, ObjectId, UpdateResult } from 'mongodb'; import { getDatabase } from '../mongoClient/dbClient'; import { Production, ProductionWithId } from '../../interfaces/production'; import { Log } from '../logger'; +import { SourceReference, Type } from '../../interfaces/Source'; export async function getProductions(): Promise { const db = await getDatabase(); diff --git a/src/api/manager/sources.ts b/src/api/manager/sources.ts index 8bb83e8..a54fb38 100644 --- a/src/api/manager/sources.ts +++ b/src/api/manager/sources.ts @@ -1,6 +1,6 @@ import inventory from './mocks/inventory.json'; import { Source } from '../../interfaces/Source'; -import { ObjectId } from 'mongodb'; +import { ObjectId, OptionalId } from 'mongodb'; import { getDatabase } from '../mongoClient/dbClient'; export function getMockedSources() { @@ -9,37 +9,44 @@ export function getMockedSources() { export async function postSource(data: Source): Promise { const db = await getDatabase(); - return (await db.collection('inventory').insertOne(data)) - .insertedId as ObjectId; + const insertData: OptionalId> & { _id?: ObjectId } = { + ...data, + _id: typeof data._id === 'string' ? new ObjectId(data._id) : data._id + }; + const result = await db.collection('inventory').insertOne(insertData); + return result.insertedId as ObjectId; } export async function getSources() { const db = await getDatabase(); return await db.collection('inventory').find().toArray(); } - export async function getSourcesByIds(_ids: string[]) { const db = await getDatabase().catch(() => { - throw "Can't connect to Database"; - }); - const objectIds = _ids.map((id: string) => { - return new ObjectId(id); + throw new Error("Can't connect to Database"); }); - return ( - await db - .collection('inventory') - .find({ - _id: { - $in: objectIds - } - }) - .toArray() - ).sort( - (a, b) => - _ids.findIndex((id) => a._id.equals(id)) - - _ids.findIndex((id) => b._id.equals(id)) - ); + const objectIds = _ids.map((id: string) => new ObjectId(id)); + + const sources = await db + .collection('inventory') + .find({ + _id: { + $in: objectIds + } + }) + .toArray(); + + return sources.sort((a, b) => { + const findIndex = (id: ObjectId | string) => + _ids.findIndex((originalId) => + id instanceof ObjectId + ? id.equals(new ObjectId(originalId)) + : id === originalId + ); + + return findIndex(a._id) - findIndex(b._id); + }); } export async function updateSource(source: any) { diff --git a/src/api/manager/workflow.ts b/src/api/manager/workflow.ts index 30678a7..8edc5a9 100644 --- a/src/api/manager/workflow.ts +++ b/src/api/manager/workflow.ts @@ -12,43 +12,45 @@ import { getPipelines, removePipelineStreams, resetPipeline -} from '../agileLive/pipelines/pipelines'; +} from '../ateliereLive/pipelines/pipelines'; import { createMultiviewForPipeline, deleteAllMultiviewsFromPipeline -} from '../agileLive/pipelines/multiviews/multiviews'; +} from '../ateliereLive/pipelines/multiviews/multiviews'; import { getSourceIdFromSourceName, getUuidFromIngestName -} from '../agileLive/ingest'; +} from '../ateliereLive/ingest'; import { PipelineStreamSettings } from '../../interfaces/pipeline'; import { connectIngestToPipeline, deleteStreamByUuid -} from '../agileLive/streams'; -import { disconnectReceiver } from '../agileLive/controlconnections'; +} from '../ateliereLive/streams'; +import { disconnectReceiver } from '../ateliereLive/controlconnections'; import { ResourcesCompactPipelineResponse, ResourcesConnectionUUIDResponse, ResourcesPipelineResponse, ResourcesReceiverNetworkEndpoint, ResourcesSenderNetworkEndpoint -} from '../../../types/agile-live'; +} from '../../../types/ateliere-live'; import { getSourcesByIds } from './sources'; import { SourceWithId, SourceToPipelineStream } from '../../interfaces/Source'; import { getAvailablePortsForIngest, getCurrentlyUsedPorts, initDedicatedPorts -} from '../agileLive/utils/fwConfigPorts'; +} from '../ateliereLive/utils/fwConfigPorts'; import { getAudioMapping } from './inventory'; import { Log } from '../logger'; import { putProduction } from './productions'; -import { getControlPanels } from '../agileLive/controlpanels'; +import { getControlPanels } from '../ateliereLive/controlpanels'; import { Result } from '../../interfaces/result'; import { Monitoring } from '../../interfaces/monitoring'; import { getDatabase } from '../mongoClient/dbClient'; import { updatedMonitoringForProduction } from './job/syncMonitoring'; +import { createControlPanelWebSocket } from '../ateliereLive/websocket'; +import { ObjectId } from 'mongodb'; const isUsed = (pipeline: ResourcesPipelineResponse) => { const hasStreams = pipeline.streams.length > 0; @@ -89,7 +91,7 @@ async function connectIngestSources( source.ingest_source_name, false ); - const audioSettings = await getAudioMapping(source._id); + const audioSettings = await getAudioMapping(new ObjectId(source._id)); const newAudioMapping = audioSettings?.audio_stream?.audio_mapping; const audioMapping = newAudioMapping?.length ? newAudioMapping : [[0, 1]]; @@ -308,6 +310,14 @@ export async function stopProduction( (p) => p.pipeline_id ); + const controlPanelWS = await createControlPanelWebSocket(); + const htmlSources = production.sources.filter( + (source) => source.type === 'html' + ); + const mediaPlayerSources = production.sources.filter( + (source) => source.type === 'mediaplayer' + ); + for (const source of production.sources) { for (const stream_uuid of source.stream_uuids || []) { await deleteStreamByUuid(stream_uuid).catch((error) => { @@ -316,6 +326,11 @@ export async function stopProduction( } } + htmlSources.map((source) => controlPanelWS.closeHtml(source.input_slot)); + mediaPlayerSources.map((source) => + controlPanelWS.closeMediaplayer(source.input_slot) + ); + for (const id of pipelineIds) { Log().info(`Stopping pipeline '${id}'`); if (!id) continue; @@ -434,6 +449,7 @@ export async function stopProduction( ] }; } + export async function startProduction( production: Production ): Promise> { @@ -448,10 +464,30 @@ export async function startProduction( // Try to setup streams from ingest(s) to pipeline(s) start try { // Get sources from the DB + // Skapa en createHtmlWebSocket, spara + const controlPanelWS = await createControlPanelWebSocket(); + const htmlSources = production.sources.filter( + (source) => source.type === 'html' + ); + const mediaPlayerSources = production.sources.filter( + (source) => source.type === 'mediaplayer' + ); + + htmlSources.map((source) => controlPanelWS.createHtml(source.input_slot)); + mediaPlayerSources.map((source) => + controlPanelWS.createMediaplayer(source.input_slot) + ); + + controlPanelWS.close(); + + // Nedan behöver göras efter att vi har skapat en produktion + // TODO: Hämta production.sources, för varje html-reference --> create i createHtmlWebSocket, för varje mediaplayer i production.sources skapa en createWebSocket const sources = await getSourcesByIds( - production.sources.map((source) => { - return source._id.toString(); - }) + production.sources + .filter((source) => source._id !== undefined) + .map((source) => { + return source._id!.toString(); + }) ).catch((error) => { if (error === "Can't connect to Database") { throw "Can't connect to Database"; @@ -465,7 +501,7 @@ export async function startProduction( throw error; }); - // Fetch expanded pipeline objects from Agile Live + // Fetch expanded pipeline objects from Ateliere Live const pipelinesToUsePromises = production_settings.pipelines.map( (pipeline) => { return getPipeline(pipeline.pipeline_id!); @@ -498,7 +534,7 @@ export async function startProduction( throw `Failed to reset pipelines: ${error}`; }); - // Fetch all control panels from Agile Live + // Fetch all control panels from Ateliere Live const allControlPanels = await getControlPanels(); // Check which control panels that should be used by this production const controlPanelsToUse = allControlPanels.filter((controlPanel) => @@ -530,7 +566,7 @@ export async function startProduction( throw `Failed to stop pipelines during startup: ${error}`; }); - // TODO: This will fetch the pipelines once again from Agile Live, but we already have them in pipelinesToUse + // TODO: This will fetch the pipelines once again from Ateliere Live, but we already have them in pipelinesToUse const usedPorts = await getCurrentlyUsedPorts( pipelinesToUse.map((pipeline) => { return pipeline.uuid; @@ -572,7 +608,7 @@ export async function startProduction( // Try to connect control panels and pipeline-to-pipeline connections start try { - // TODO: This will re-fetch pipelines from the Agile Live API, but we fetched them above into pipelinesToUse + // TODO: This will re-fetch pipelines from the Ateliere Live API, but we fetched them above into pipelinesToUse await connectControlPanelToPipeline( production_settings.control_connection, production_settings.pipelines @@ -650,15 +686,14 @@ export async function startProduction( // Try to setup multiviews start try { - let multiviewId = 0; - if (!production.production_settings.pipelines[0].multiview) { + if (!production.production_settings.pipelines[0].multiviews) { Log().error( `No multiview settings specified for production: ${production.name}` ); throw `No multiview settings specified for production: ${production.name}`; } - const multiview = await createMultiviewForPipeline( + const runtimeMultiviews = await createMultiviewForPipeline( production_settings, production.sources ).catch(async (error) => { @@ -674,13 +709,17 @@ export async function startProduction( }); throw `Failed to create multiview for pipeline '${production_settings.pipelines[0].pipeline_name}/${production_settings.pipelines[0].pipeline_id}': ${error}`; }); - multiviewId = multiview.id; + + runtimeMultiviews.flatMap((runtimeMultiview, index) => { + const multiview = production.production_settings.pipelines[0].multiviews; + if (multiview && multiview[index]) { + return (multiview[index].multiview_id = runtimeMultiview.id); + } + }); Log().info( `Production '${production.name}' with preset '${production_settings.name}' started` ); - production.production_settings.pipelines[0].multiview.multiview_id = - multiviewId; } catch (e) { Log().error('Could not start multiviews'); Log().error(e); @@ -716,7 +755,7 @@ export async function startProduction( ...production, sources: production.sources.map((source) => { const streamsForSource = streams?.filter( - (stream) => stream.source_id === source._id.toString() + (stream) => stream.source_id === source._id?.toString() ); return { ...source, diff --git a/src/app/api/manager/controlpanels/route.ts b/src/app/api/manager/controlpanels/route.ts index c91e607..360779f 100644 --- a/src/app/api/manager/controlpanels/route.ts +++ b/src/app/api/manager/controlpanels/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { isAuthenticated } from '../../../../api/manager/auth'; -import { getControlPanels } from '../../../../api/agileLive/controlpanels'; +import { getControlPanels } from '../../../../api/ateliereLive/controlpanels'; export async function GET(request: NextRequest): Promise { if (!(await isAuthenticated())) { diff --git a/src/app/api/manager/inventory/[_id]/route.ts b/src/app/api/manager/inventory/[_id]/route.ts new file mode 100644 index 0000000..86eb382 --- /dev/null +++ b/src/app/api/manager/inventory/[_id]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthenticated } from '../../../../../api/manager/auth'; +import { Params } from 'next/dist/shared/lib/router/utils/route-matcher'; +import { purgeInventorySourceItem } from '../../../../../api/manager/inventory'; + +export async function PUT( + request: NextRequest, + { params }: { params: Params } +): Promise { + if (!(await isAuthenticated())) { + return new NextResponse(`Not Authorized!`, { + status: 403 + }); + } + + try { + const response = await purgeInventorySourceItem(params._id); + if (response.acknowledged && response.modifiedCount === 0) { + return new NextResponse(`Did not match requirements`, { + status: 204 + }); + } else if (response.acknowledged) { + return new NextResponse(null, { + status: 200 + }); + } else { + return new NextResponse(`Could not update database-status`, { + status: 500 + }); + } + } catch (error) { + return new NextResponse( + `Error occurred while posting to DB! Error: ${error}`, + { + status: 500 + } + ); + } +} diff --git a/src/app/api/manager/multiviews/[id]/route.ts b/src/app/api/manager/multiviews/[id]/route.ts index ff1193d..4390f37 100644 --- a/src/app/api/manager/multiviews/[id]/route.ts +++ b/src/app/api/manager/multiviews/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { isAuthenticated } from '../../../../../api/manager/auth'; import { getMultiviewPreset } from '../../../../../api/manager/presets'; import { Params } from 'next/dist/shared/lib/router/utils/route-matcher'; -import { updateMultiviewForPipeline } from '../../../../../api/agileLive/pipelines/multiviews/multiviews'; +import { updateMultiviewForPipeline } from '../../../../../api/ateliereLive/pipelines/multiviews/multiviews'; import { MultiviewViews } from '../../../../../interfaces/multiview'; export async function GET( diff --git a/src/app/api/manager/pipelines/[id]/route.ts b/src/app/api/manager/pipelines/[id]/route.ts index 54055a6..9f8a6a4 100644 --- a/src/app/api/manager/pipelines/[id]/route.ts +++ b/src/app/api/manager/pipelines/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPipeline } from '../../../../../api/agileLive/pipelines/pipelines'; -import { isActive } from '../../../../../api/agileLive/utils/pipeline'; +import { getPipeline } from '../../../../../api/ateliereLive/pipelines/pipelines'; +import { isActive } from '../../../../../api/ateliereLive/utils/pipeline'; import { getSRTMultiviews, getSRTOutputs, diff --git a/src/app/api/manager/pipelines/route.ts b/src/app/api/manager/pipelines/route.ts index 498faab..1b73e08 100644 --- a/src/app/api/manager/pipelines/route.ts +++ b/src/app/api/manager/pipelines/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPipelines } from '../../../../api/agileLive/pipelines/pipelines'; +import { getPipelines } from '../../../../api/ateliereLive/pipelines/pipelines'; import { isAuthenticated } from '../../../../api/manager/auth'; export async function GET(request: NextRequest): Promise { diff --git a/src/app/api/manager/sources/[ingest_name]/[source_name]/thumbnail/route.ts b/src/app/api/manager/sources/[ingest_name]/[source_name]/thumbnail/route.ts index d9efdd9..926d3e3 100644 --- a/src/app/api/manager/sources/[ingest_name]/[source_name]/thumbnail/route.ts +++ b/src/app/api/manager/sources/[ingest_name]/[source_name]/thumbnail/route.ts @@ -3,7 +3,7 @@ import { getSourceIdFromSourceName, getSourceThumbnail, getUuidFromIngestName -} from '../../../../../../../api/agileLive/ingest'; +} from '../../../../../../../api/ateliereLive/ingest'; import { isAuthenticated } from '../../../../../../../api/manager/auth'; type Params = { diff --git a/src/app/api/manager/streams/[id]/route.ts b/src/app/api/manager/streams/[id]/route.ts index a5a23e4..00d44df 100644 --- a/src/app/api/manager/streams/[id]/route.ts +++ b/src/app/api/manager/streams/[id]/route.ts @@ -1,9 +1,9 @@ import { Params } from 'next/dist/shared/lib/router/utils/route-matcher'; import { NextRequest, NextResponse } from 'next/server'; import { isAuthenticated } from '../../../../../api/manager/auth'; -import { deleteStream } from '../../../../../api/agileLive/pipelines/streams/streams'; +import { deleteStream } from '../../../../../api/ateliereLive/pipelines/streams/streams'; import { MultiviewSettings } from '../../../../../interfaces/multiview'; -import { updateMultiviewForPipeline } from '../../../../../api/agileLive/pipelines/multiviews/multiviews'; +import { updateMultiviewForPipeline } from '../../../../../api/ateliereLive/pipelines/multiviews/multiviews'; import { DeleteSourceStep } from '../../../../../interfaces/Source'; import { Result } from '../../../../../interfaces/result'; import { Log } from '../../../../../api/logger'; @@ -18,13 +18,13 @@ export async function DELETE( }); } const body = await request.json(); - const multiview = body.multiview as MultiviewSettings; + const multiview = body.multiview as MultiviewSettings[]; try { await deleteStream(params.id).catch((e) => { Log().error(`Failed to delete stream: ${params.id}: ${e.message}`); throw `Failed to delete stream: ${params.id}: ${e.message}`; }); - if (!multiview) { + if (!multiview || multiview.length === 0) { return new NextResponse( JSON.stringify({ ok: true, @@ -68,17 +68,21 @@ export async function DELETE( ); } try { - if (!multiview.multiview_id) { - throw `The provided multiview settings did not contain any multiview id`; - } - - await updateMultiviewForPipeline( - body.pipelineUUID, - multiview.multiview_id, - multiview.layout.views - ).catch((e) => { - throw `Error when updating multiview: ${e.message}`; + const multiviewUpdates = multiview.map(async (singleMultiview) => { + if (!singleMultiview.multiview_id) { + throw `The provided multiview settings did not contain any multiview id`; + } + return updateMultiviewForPipeline( + body.pipelineUUID, + singleMultiview.multiview_id, + singleMultiview.layout.views + ).catch((e) => { + throw `Error when updating multiview: ${e.message}`; + }); }); + + await Promise.all(multiviewUpdates); + return new NextResponse( JSON.stringify({ ok: true, diff --git a/src/app/api/manager/streams/route.ts b/src/app/api/manager/streams/route.ts index 160c736..4463588 100644 --- a/src/app/api/manager/streams/route.ts +++ b/src/app/api/manager/streams/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { isAuthenticated } from '../../../../api/manager/auth'; import { SourceWithId } from '../../../../interfaces/Source'; import { Production } from '../../../../interfaces/production'; -import { createStream } from '../../../../api/agileLive/pipelines/streams/streams'; +import { createStream } from '../../../../api/ateliereLive/pipelines/streams/streams'; import { Log } from '../../../../api/logger'; export type CreateStreamRequestBody = { source: SourceWithId; diff --git a/src/app/api/syshealth/route.ts b/src/app/api/syshealth/route.ts index 0f58ed9..868a2f1 100644 --- a/src/app/api/syshealth/route.ts +++ b/src/app/api/syshealth/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; -import { getIngests } from '../../../api/agileLive/ingest'; +import { getIngests } from '../../../api/ateliereLive/ingest'; import { connected } from '../../../api/mongoClient/dbClient'; import { isAuthenticated } from '../../../api/manager/auth'; -import { AGILE_BASE_API_PATH } from '../../../constants'; +import { LIVE_BASE_API_PATH } from '../../../constants'; export async function GET(): Promise { if (!(await isAuthenticated())) { @@ -10,20 +10,20 @@ export async function GET(): Promise { status: 403 }); } - const isConnectedToAgile = await getIngests() + const isConnectedToLive = await getIngests() .then(() => true) .catch(() => false); const isConnectedToDatabase = await connected().catch(() => false); - if (isConnectedToAgile && isConnectedToDatabase) { + if (isConnectedToLive && isConnectedToDatabase) { return new NextResponse( JSON.stringify({ message: 'Connected!', database: { connected: isConnectedToDatabase }, - agileApi: { connected: isConnectedToAgile } + liveApi: { connected: isConnectedToLive } }), { status: 200 @@ -36,9 +36,9 @@ export async function GET(): Promise { return new NextResponse( JSON.stringify({ message: 'Something went wrong with the connection!', - agileApi: { - connected: isConnectedToAgile, - url: new URL(AGILE_BASE_API_PATH, process.env.AGILE_URL) + liveApi: { + connected: isConnectedToLive, + url: new URL(LIVE_BASE_API_PATH, process.env.LIVE_URL) }, database: { connected: isConnectedToDatabase, diff --git a/src/app/html_input/page.tsx b/src/app/html_input/page.tsx new file mode 100644 index 0000000..81cfaa5 --- /dev/null +++ b/src/app/html_input/page.tsx @@ -0,0 +1,10 @@ +import { PageProps } from '../../../.next/types/app/html_input/page'; + +export default function HtmlInput({ searchParams: { input } }: PageProps) { + return ( +
+

HTML INPUT

+

{input}

+
+ ); +} diff --git a/src/app/production/[id]/page.tsx b/src/app/production/[id]/page.tsx index 8faac49..0b4259d 100644 --- a/src/app/production/[id]/page.tsx +++ b/src/app/production/[id]/page.tsx @@ -3,14 +3,15 @@ import React, { useEffect, useState, KeyboardEvent } from 'react'; import { PageProps } from '../../../../.next/types/app/production/[id]/page'; import SourceListItem from '../../../components/sourceListItem/SourceListItem'; import FilterOptions from '../../../components/filter/FilterOptions'; -import { AddSource } from '../../../components/addSource/AddSource'; +import { AddInput } from '../../../components/addInput/AddInput'; import { IconX } from '@tabler/icons-react'; import { useSources } from '../../../hooks/sources/useSources'; import { AddSourceStatus, DeleteSourceStatus, SourceReference, - SourceWithId + SourceWithId, + Type } from '../../../interfaces/Source'; import { useGetProduction, usePutProduction } from '../../../hooks/productions'; import { Production } from '../../../interfaces/production'; @@ -40,8 +41,9 @@ import { RemoveSourceModal } from '../../../components/modal/RemoveSourceModal'; import { useDeleteStream, useCreateStream } from '../../../hooks/streams'; import { MonitoringButton } from '../../../components/button/MonitoringButton'; import { useGetMultiviewPreset } from '../../../hooks/multiviewPreset'; -import { ISource } from '../../../hooks/useDragableItems'; import { useMultiviews } from '../../../hooks/multiviews'; +import { v4 as uuidv4 } from 'uuid'; +import { Select } from '../../../components/select/Select'; export default function ProductionConfiguration({ params }: PageProps) { const t = useTranslate(); @@ -51,6 +53,9 @@ export default function ProductionConfiguration({ params }: PageProps) { const [filteredSources, setFilteredSources] = useState( new Map() ); + const [selectedValue, setSelectedValue] = useState( + t('production.add_other_source_type') + ); const [addSourceModal, setAddSourceModal] = useState(false); const [removeSourceModal, setRemoveSourceModal] = useState(false); const [selectedSource, setSelectedSource] = useState< @@ -59,6 +64,8 @@ export default function ProductionConfiguration({ params }: PageProps) { const [selectedSourceRef, setSelectedSourceRef] = useState< SourceReference | undefined >(); + const [sourceReferenceToAdd, setSourceReferenceToAdd] = + useState(); const [createStream, loadingCreateStream] = useCreateStream(); const [deleteStream, loadingDeleteStream] = useDeleteStream(); //PRODUCTION @@ -77,7 +84,7 @@ export default function ProductionConfiguration({ params }: PageProps) { const getMultiviewPreset = useGetMultiviewPreset(); const [updateMultiviewViews] = useMultiviews(); - //FROM AGILE API + //FROM LIVE API const [pipelines, loadingPipelines, , refreshPipelines] = usePipelines(); const [controlPanels, loadingControlPanels, , refreshControlPanels] = useControlPanels(); @@ -88,11 +95,39 @@ export default function ProductionConfiguration({ params }: PageProps) { const [deleteSourceStatus, setDeleteSourceStatus] = useState(); + const isAddButtonDisabled = + selectedValue !== 'HTML' && selectedValue !== 'Media Player'; + useEffect(() => { refreshPipelines(); refreshControlPanels(); }, [productionSetup?.isActive]); + // TODO: Väldigt lik den för ingest_source --> ändra?? + const addSourceToProduction = (type: Type) => { + const newSource: SourceReference = { + _id: uuidv4(), + type: type, + label: type === 'html' ? 'HTML Input' : 'Media Player Source', + input_slot: getFirstEmptySlot() + }; + + setSourceReferenceToAdd(newSource); + + if (productionSetup) { + const updatedSetup = addSetupItem(newSource, productionSetup); + + if (!updatedSetup) return; + setProductionSetup(updatedSetup); + putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { + refreshProduction(); + setAddSourceModal(false); + setSourceReferenceToAdd(undefined); + }); + setAddSourceStatus(undefined); + } + }; + const setSelectedControlPanel = (controlPanel: string[]) => { setProductionSetup((prevState) => { if (!prevState) return; @@ -219,6 +254,12 @@ export default function ProductionConfiguration({ params }: PageProps) { setFilteredSources(sources); }, [sources]); + useEffect(() => { + if (selectedValue === t('production.source')) { + setInventoryVisible(true); + } + }, [selectedValue]); + const updatePreset = (preset: Preset) => { if (!productionSetup?._id) return; putProduction(productionSetup?._id.toString(), { @@ -243,6 +284,7 @@ export default function ProductionConfiguration({ params }: PageProps) { refreshProduction(); }); }; + const updateProduction = (id: string, productionSetup: Production) => { setProductionSetup(productionSetup); putProduction(id, productionSetup); @@ -256,13 +298,21 @@ export default function ProductionConfiguration({ params }: PageProps) { setProductionSetup(updatedSetup); putProduction(updatedSetup._id.toString(), updatedSetup); const pipeline = updatedSetup.production_settings.pipelines[0]; - if ( - pipeline.pipeline_id && - pipeline.multiview && - pipeline.multiview.multiview_id - ) { - updateMultiviewViews(pipeline.pipeline_id, updatedSetup, source); - } + + pipeline.multiviews?.map((singleMultiview) => { + if ( + pipeline.pipeline_id && + pipeline.multiviews && + singleMultiview.multiview_id + ) { + updateMultiviewViews( + pipeline.pipeline_id, + updatedSetup, + source, + singleMultiview + ); + } + }); }; const updateConfigName = (nameChange: string) => { @@ -293,7 +343,6 @@ export default function ProductionConfiguration({ params }: PageProps) { const defaultMultiview = await getMultiviewPreset( preset?.default_multiview_reference ); - setSelectedPreset(preset); const multiview = { @@ -329,7 +378,7 @@ export default function ProductionConfiguration({ params }: PageProps) { pipelines: preset.pipelines } } as Production; - updatedSetup.production_settings.pipelines[0].multiview = multiview; + updatedSetup.production_settings.pipelines[0].multiviews = [multiview]; setProductionSetup(updatedSetup); } @@ -371,6 +420,7 @@ export default function ProductionConfiguration({ params }: PageProps) { const updatedSetup = addSetupItem( { _id: source._id.toString(), + type: 'ingest_source', label: source.ingest_source_name, input_slot: getFirstEmptySlot() }, @@ -419,7 +469,13 @@ export default function ProductionConfiguration({ params }: PageProps) { productionSetup && productionSetup.isActive && selectedSource && - productionSetup.production_settings.pipelines[0].multiview?.layout.views + (Array.isArray( + productionSetup?.production_settings.pipelines[0].multiviews + ) + ? productionSetup.production_settings.pipelines[0].multiviews.some( + (singleMultiview) => singleMultiview?.layout?.views + ) + : false) ) { const firstEmptySlot = getFirstEmptySlot(); const result = await createStream( @@ -442,8 +498,9 @@ export default function ProductionConfiguration({ params }: PageProps) { } if (result.ok) { if (result.value.success) { - const sourceToAdd = { + const sourceToAdd: SourceReference = { _id: result.value.streams[0].source_id, + type: 'ingest_source', label: selectedSource.name, stream_uuids: result.value.streams.map((r) => r.stream_uuid), input_slot: getFirstEmptySlot() @@ -471,20 +528,27 @@ export default function ProductionConfiguration({ params }: PageProps) { selectedSourceRef && selectedSourceRef.stream_uuids ) { - const multiview = - productionSetup.production_settings.pipelines[0].multiview; - if (!multiview) return; - const viewToUpdate = multiview?.layout.views.find( - (v) => v.input_slot === selectedSourceRef.input_slot + const multiviews = + productionSetup.production_settings.pipelines[0].multiviews; + + if (!multiviews || multiviews.length === 0) return; + + const viewToUpdate = multiviews.some((multiview) => + multiview.layout.views.find( + (v) => v.input_slot === selectedSourceRef.input_slot + ) ); + if (!viewToUpdate) { if (!productionSetup.production_settings.pipelines[0].pipeline_id) return; + const result = await deleteStream( selectedSourceRef.stream_uuids, productionSetup, selectedSourceRef.input_slot ); + if (!result.ok) { if (!result.value) { setDeleteSourceStatus({ @@ -513,11 +577,14 @@ export default function ProductionConfiguration({ params }: PageProps) { } return; } + const updatedSetup = removeSetupItem( selectedSourceRef, productionSetup ); + if (!updatedSetup) return; + setProductionSetup(updatedSetup); putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { setRemoveSourceModal(false); @@ -525,11 +592,13 @@ export default function ProductionConfiguration({ params }: PageProps) { }); return; } + const result = await deleteStream( selectedSourceRef.stream_uuids, productionSetup, selectedSourceRef.input_slot ); + if (!result.ok) { if (!result.value) { setDeleteSourceStatus({ @@ -575,6 +644,7 @@ export default function ProductionConfiguration({ params }: PageProps) { setSelectedSource(undefined); setDeleteSourceStatus(undefined); }; + return ( <> @@ -623,13 +693,13 @@ export default function ProductionConfiguration({ params }: PageProps) { /> -
+
-
+
    @@ -674,15 +744,12 @@ export default function ProductionConfiguration({ params }: PageProps) { {productionSetup?.sources && sources.size > 0 && ( { updateProduction(productionSetup._id, updated); }} - onSourceUpdate={( - source: SourceReference, - sourceItem: ISource - ) => { - sourceItem.label = source.label; + onSourceUpdate={(source: SourceReference) => { updateSource(source, productionSetup); }} onSourceRemoval={(source: SourceReference) => { @@ -693,6 +760,7 @@ export default function ProductionConfiguration({ params }: PageProps) { const updatedSetup = removeSetupItem( { _id: source._id, + type: source.type, label: source.label, input_slot: source.input_slot }, @@ -722,15 +790,43 @@ export default function ProductionConfiguration({ params }: PageProps) { )} )} - { - setInventoryVisible(true); - }} - /> +
    + setInventoryVisible(true)} + disabled={ + productionSetup?.production_settings === undefined || + productionSetup.production_settings === null + } + /> +
    + { + setSelectedValue(e.target.value); + }} + options={[ + t('inventory_list.no_sorting_applied'), + t('inventory_list.most_recent_connection') + ]} + /> + +
  • @@ -222,17 +304,64 @@ function FilterDropdown({
    +
    + + +
    +
    + + +
    +
    + + +
    +
  • +
    diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx index 1b06001..60b628e 100644 --- a/src/components/footer/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -13,7 +13,7 @@ interface IObject { interface IConnection { database?: IObject; - agileApi?: IObject; + liveApi?: IObject; message?: string; } @@ -63,7 +63,7 @@ export default function Footer() { }, []); const settingsDb = connection?.database; - const settingsAgile = connection?.agileApi; + const settingsLive = connection?.liveApi; return (
    @@ -77,8 +77,8 @@ export default function Footer() { /> ({ - input: [{ name: '', location: '', type: '', audioMapping: [] }, () => null], + input: [ + { name: '', location: '', type: '', lastConnected: '', audioMapping: [] }, + () => null + ], saved: [undefined, () => null], loading: false, isSame: true, @@ -66,6 +70,7 @@ export default function Context({ name: source.name, location: source.tags.location, type: source.type, + lastConnected: source.lastConnected, // audioMapping: source?.stream_settings?.audio_mapping || [] audioMapping: source?.audio_stream.audio_mapping || [] }); @@ -81,6 +86,7 @@ export default function Context({ name: source.name, location: source.tags.location, type: source.type, + lastConnected: source.lastConnected, // audioMapping: source?.stream_settings?.audio_mapping || [] audioMapping: source?.audio_stream.audio_mapping || [] })); diff --git a/src/components/inventory/Inventory.tsx b/src/components/inventory/Inventory.tsx index 1943bf1..7603616 100644 --- a/src/components/inventory/Inventory.tsx +++ b/src/components/inventory/Inventory.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useSources } from '../../hooks/sources/useSources'; +import { useSetSourceToPurge } from '../../hooks/sources/useSetSourceToPurge'; import FilterOptions from '../../components/filter/FilterOptions'; import SourceListItem from '../../components/sourceListItem/SourceListItem'; import { SourceWithId } from '../../interfaces/Source'; @@ -10,13 +11,15 @@ import FilterContext from './FilterContext'; import styles from './Inventory.module.scss'; export default function Inventory() { + const [removeInventorySource, reloadList] = useSetSourceToPurge(); const [updatedSource, setUpdatedSource] = useState< SourceWithId | undefined >(); - const [sources] = useSources(updatedSource); + const [sources] = useSources(reloadList, updatedSource); const [currentSource, setCurrentSource] = useState(); const [filteredSources, setFilteredSources] = useState>(sources); + const inventoryVisible = true; useEffect(() => { @@ -25,6 +28,12 @@ export default function Inventory() { } }, [updatedSource]); + useEffect(() => { + if (reloadList) { + setCurrentSource(null); + } + }, [reloadList]); + const editSource = (source: SourceWithId) => { setCurrentSource(() => source); }; @@ -35,23 +44,25 @@ export default function Inventory() { return Array.from( filteredSources.size > 0 ? filteredSources.values() : sources.values() ).map((source, index) => { - return ( - { - editSource(source); - }} - /> - ); + if (source.status !== 'purge') { + return ( + { + editSource(source); + }} + /> + ); + } }); } return ( -
    +
      {getSourcesToDisplay(filteredSources)}
    @@ -79,11 +90,14 @@ export default function Inventory() {
    {currentSource ? ( -
    +
    setUpdatedSource(source)} close={() => setCurrentSource(null)} + removeInventorySource={(source) => removeInventorySource(source)} />
    ) : null} diff --git a/src/components/inventory/editView/EditView.tsx b/src/components/inventory/editView/EditView.tsx index b86ff12..80d498a 100644 --- a/src/components/inventory/editView/EditView.tsx +++ b/src/components/inventory/editView/EditView.tsx @@ -11,14 +11,17 @@ import { IconExclamationCircle } from '@tabler/icons-react'; export default function EditView({ source, updateSource, - close + close, + removeInventorySource }: { source: SourceWithId; updateSource: (source: SourceWithId) => void; close: () => void; + removeInventorySource: (source: SourceWithId) => void; }) { const [loaded, setLoaded] = useState(false); const src = useMemo(() => getSourceThumbnail(source), [source]); + return (
    @@ -49,8 +52,11 @@ export default function EditView({
    - - + ); } diff --git a/src/components/inventory/editView/GeneralSettings.tsx b/src/components/inventory/editView/GeneralSettings.tsx index 0090d17..ae2c4c6 100644 --- a/src/components/inventory/editView/GeneralSettings.tsx +++ b/src/components/inventory/editView/GeneralSettings.tsx @@ -70,6 +70,15 @@ export default function GeneralSettings() {
    +
    +

    + {t('source.last_connected')} +

    +
    +

    {new Date(input.lastConnected).toLocaleString()}

    +
    +
    + {height && width && (

    {t('video')}

    diff --git a/src/components/inventory/editView/UpdateButtons.tsx b/src/components/inventory/editView/UpdateButtons.tsx index 88c5f03..1f3309d 100644 --- a/src/components/inventory/editView/UpdateButtons.tsx +++ b/src/components/inventory/editView/UpdateButtons.tsx @@ -4,8 +4,18 @@ import { EditViewContext } from '../EditViewContext'; import { useTranslate } from '../../../i18n/useTranslate'; import styles from './animation.module.scss'; import { Loader } from '../../loader/Loader'; +import { SourceWithId } from '../../../interfaces/Source'; +import { IconTrash } from '@tabler/icons-react'; -export default function UpdateButtons({ close }: { close: () => void }) { +export default function UpdateButtons({ + close, + removeInventorySource, + source +}: { + close: () => void; + removeInventorySource: (source: SourceWithId) => void; + source: SourceWithId; +}) { const t = useTranslate(); const { saved: [saved], @@ -22,6 +32,15 @@ export default function UpdateButtons({ close }: { close: () => void }) {
    + diff --git a/src/components/layout/DefaultLayout.tsx b/src/components/layout/DefaultLayout.tsx index 9723e84..8ddef3b 100644 --- a/src/components/layout/DefaultLayout.tsx +++ b/src/components/layout/DefaultLayout.tsx @@ -7,7 +7,7 @@ export default function DefaultLayout({ }) { return (
    -
    +
    {children}
    diff --git a/src/components/modal/AddSourceModal.tsx b/src/components/modal/AddSourceModal.tsx index bacb965..d81c171 100644 --- a/src/components/modal/AddSourceModal.tsx +++ b/src/components/modal/AddSourceModal.tsx @@ -26,8 +26,8 @@ export function AddSourceModal({ return (
    +

    HEJ

    {t('workflow.add_source_modal', { name })}

    -
    {status && }
    + )} + {multiviews.length === index + 1 && ( + + )} +
    +
    +
    + ); + })}
    clearInputs()} onSave={onSave} /> diff --git a/src/components/modal/configureOutputModal/Input.tsx b/src/components/modal/configureOutputModal/Input.tsx index 15c585d..25c0954 100644 --- a/src/components/modal/configureOutputModal/Input.tsx +++ b/src/components/modal/configureOutputModal/Input.tsx @@ -7,6 +7,7 @@ interface IInput { update: (value: string) => void; onKeyDown?: (e: KeyboardEvent) => void; size?: 'small' | 'large'; + inputError?: boolean; } export default function Input({ @@ -15,8 +16,11 @@ export default function Input({ update, type = 'text', onKeyDown, - size = 'small' + size = 'small', + inputError }: IInput) { + const errorCss = 'border-red-500 focus:border-red-500 focus:outline'; + return (
    @@ -27,7 +31,9 @@ export default function Input({ onChange={(e) => update(e.target.value)} className={`cursor-pointer border text-sm rounded-lg ${ size === 'small' ? 'w-6/12' : 'w-7/12' - } pl-2 pt-1 pb-1 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:border-gray-400 focus:outline-none`} + } pl-2 pt-1 pb-1 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:border-gray-400 focus:outline-none ${ + inputError ? errorCss : '' + }`} />
    ); diff --git a/src/components/modal/configureOutputModal/MultiviewSettings.tsx b/src/components/modal/configureOutputModal/MultiviewSettings.tsx index abc305a..e742005 100644 --- a/src/components/modal/configureOutputModal/MultiviewSettings.tsx +++ b/src/components/modal/configureOutputModal/MultiviewSettings.tsx @@ -3,7 +3,6 @@ import { useMultiviewPresets } from '../../../hooks/multiviewPreset'; import { useTranslate } from '../../../i18n/useTranslate'; import { MultiviewSettings } from '../../../interfaces/multiview'; import { MultiviewPreset } from '../../../interfaces/preset'; - import Input from './Input'; import Options from './Options'; import toast from 'react-hot-toast'; @@ -11,31 +10,38 @@ import toast from 'react-hot-toast'; type MultiviewSettingsProps = { multiview?: MultiviewSettings; handleUpdateMultiview: (multiview: MultiviewSettings) => void; + portDuplicateError: boolean; }; -export default function MultiviewOutputSettings({ +export default function MultiviewSettingsConfig({ multiview, - handleUpdateMultiview + handleUpdateMultiview, + portDuplicateError }: MultiviewSettingsProps) { const t = useTranslate(); const [multiviewPresets, loading] = useMultiviewPresets(); - const [selectedMultiviewPreset, setetSelectedMultiviewPreset] = useState< + const [selectedMultiviewPreset, setSelectedMultiviewPreset] = useState< MultiviewPreset | undefined >(multiview); + useEffect(() => { if (multiview) { - setetSelectedMultiviewPreset(multiview); + setSelectedMultiviewPreset(multiview); return; } if (multiviewPresets && multiviewPresets[0]) { - setetSelectedMultiviewPreset(multiviewPresets[0]); + setSelectedMultiviewPreset(multiviewPresets[0]); } }, [multiviewPresets, multiview]); + if (!multiview) { if (!multiviewPresets || multiviewPresets.length === 0) { return null; } - handleUpdateMultiview({ ...multiviewPresets[0], for_pipeline_idx: 0 }); + handleUpdateMultiview({ + ...multiviewPresets[0], + for_pipeline_idx: 0 + }); } const handleSetSelectedMultiviewPreset = (name: string) => { @@ -44,9 +50,10 @@ export default function MultiviewOutputSettings({ toast.error(t('preset.no_multiview_found')); return; } - setetSelectedMultiviewPreset(selected); + setSelectedMultiviewPreset(selected); handleUpdateMultiview({ ...selected, for_pipeline_idx: 0 }); }; + const getNumber = (val: string, prev: number) => { if (Number.isNaN(parseInt(val))) { return prev; @@ -133,9 +140,12 @@ export default function MultiviewOutputSettings({ : []; const multiviewOrPreset = multiview ? multiview : selectedMultiviewPreset; + return (
    -

    {t('preset.multiview_output_settings')}

    +
    +

    {t('preset.multiview_output_settings')}

    +
    ) => void; + options: readonly string[]; + classNames?: string; +}; + +export const Select = ({ + value, + onChange, + options, + classNames +}: SortSelectProps) => { + return ( + + ); +}; diff --git a/src/components/sourceCard/SourceCard.tsx b/src/components/sourceCard/SourceCard.tsx index 6d1ecd8..246e7c2 100644 --- a/src/components/sourceCard/SourceCard.tsx +++ b/src/components/sourceCard/SourceCard.tsx @@ -2,20 +2,22 @@ import React, { ChangeEvent, KeyboardEvent, useState } from 'react'; import { IconTrash } from '@tabler/icons-react'; -import { SourceReference } from '../../interfaces/Source'; +import { SourceReference, Type } from '../../interfaces/Source'; import { SourceThumbnail } from './SourceThumbnail'; import { useTranslate } from '../../i18n/useTranslate'; import { ISource } from '../../hooks/useDragableItems'; type SourceCardProps = { - source: ISource; + source?: ISource; label: string; - onSourceUpdate: (source: SourceReference, sourceItem: ISource) => void; + onSourceUpdate: (source: SourceReference) => void; onSourceRemoval: (source: SourceReference) => void; onSelectingText: (bool: boolean) => void; forwardedRef?: React.LegacyRef; style?: object; - src: string; + src?: string; + sourceRef?: SourceReference; + type: Type; }; export default function SourceCard({ @@ -26,9 +28,13 @@ export default function SourceCard({ onSelectingText, forwardedRef, src, - style + style, + sourceRef, + type }: SourceCardProps) { - const [sourceLabel, setSourceLabel] = useState(label ? label : source.name); + const [sourceLabel, setSourceLabel] = useState( + sourceRef?.label || source?.name + ); const t = useTranslate(); @@ -37,20 +43,29 @@ export default function SourceCard({ }; const saveText = () => { onSelectingText(false); - // if (source.name === label) { - // return; - // } - if (sourceLabel.length === 0) { - setSourceLabel(source.name); + if (sourceLabel?.length === 0) { + if (source) { + setSourceLabel(source.name); + } else if (sourceRef) { + setSourceLabel(sourceRef.label); + } } - onSourceUpdate( - { + + if (source) { + onSourceUpdate({ _id: source._id.toString(), - label: sourceLabel, + type: 'ingest_source', + label: sourceLabel || source.name, input_slot: source.input_slot - }, - source - ); + }); + } else if (sourceRef) { + onSourceUpdate({ + _id: sourceRef._id, + type: sourceRef.type, + label: sourceLabel || sourceRef.label, + input_slot: sourceRef.input_slot + }); + } }; const handleKeyDown = (event: KeyboardEvent) => { @@ -77,25 +92,59 @@ export default function SourceCard({ onBlur={saveText} />
    - -

    - {t('source.ingest', { - ingest: source.ingest_name - })} -

    - + {source && source.src && ( + + )} + {!source && sourceRef && } + {(sourceRef || source) && ( +

    + {t('source.input_slot', { + input_slot: + sourceRef?.input_slot?.toString() || + source?.input_slot?.toString() || + '' + })} +

    + )} + + {source && ( +

    + {t('source.ingest', { + ingest: source.ingest_name + })} +

    + )} + {(source || sourceRef) && ( + + )}
    ); } diff --git a/src/components/sourceCard/SourceThumbnail.tsx b/src/components/sourceCard/SourceThumbnail.tsx index 5aa7114..b5e5bcb 100644 --- a/src/components/sourceCard/SourceThumbnail.tsx +++ b/src/components/sourceCard/SourceThumbnail.tsx @@ -2,18 +2,19 @@ import Image from 'next/image'; import { useState } from 'react'; -import { Source } from '../../interfaces/Source'; +import { Source, Type } from '../../interfaces/Source'; import { IconExclamationCircle } from '@tabler/icons-react'; type SourceThumbnailProps = { - source: Source; - src: string; + source?: Source; + src?: string; + type?: Type; }; -export function SourceThumbnail({ source, src }: SourceThumbnailProps) { +export function SourceThumbnail({ source, src, type }: SourceThumbnailProps) { const [loaded, setLoaded] = useState(false); - if (source.status === 'gone') { + if (source && source.status === 'gone') { return (
    @@ -22,20 +23,37 @@ export function SourceThumbnail({ source, src }: SourceThumbnailProps) { } return ( - Preview Thumbnail setLoaded(true)} - onError={() => setLoaded(true)} - placeholder="empty" - width={0} - height={0} - sizes="20vh" - style={{ - width: 'auto', - height: '100%' - }} - /> + <> + {(type === 'ingest_source' || !type) && src && ( + Preview Thumbnail setLoaded(true)} + onError={() => setLoaded(true)} + placeholder="empty" + width={0} + height={0} + sizes="20vh" + style={{ + width: 'auto', + height: '100%' + }} + /> + )} + {(type === 'html' || type === 'mediaplayer') && ( + +

    + {type === 'html' ? 'HTML' : 'Media Player'} +

    +
    + )} + ); } diff --git a/src/components/sourceCards/SourceCards.tsx b/src/components/sourceCards/SourceCards.tsx index 9666bcc..d861578 100644 --- a/src/components/sourceCards/SourceCards.tsx +++ b/src/components/sourceCards/SourceCards.tsx @@ -5,108 +5,73 @@ import { SourceReference } from '../../interfaces/Source'; import { Production } from '../../interfaces/production'; import DragItem from '../dragElement/DragItem'; import SourceCard from '../sourceCard/SourceCard'; -import { EmptySlotCard } from '../emptySlotCard/EmptySlotCard'; import { ISource, useDragableItems } from '../../hooks/useDragableItems'; - export default function SourceCards({ productionSetup, + sourceRef, updateProduction, onSourceUpdate, onSourceRemoval }: { productionSetup: Production; + sourceRef?: SourceReference; updateProduction: (updated: Production) => void; - onSourceUpdate: (source: SourceReference, sourceItem: ISource) => void; + onSourceUpdate: (source: SourceReference) => void; onSourceRemoval: (source: SourceReference) => void; }) { const [items, moveItem, loading] = useDragableItems(productionSetup.sources); const [selectingText, setSelectingText] = useState(false); - const currentOrder: SourceReference[] = items.map((source) => { - return { - _id: source._id.toString(), - label: source.label, - input_slot: source.input_slot, - stream_uuids: source.stream_uuids - }; - }); - const gridItems: React.JSX.Element[] = []; - let tempItems = [...items]; - let firstEmptySlot = items.length + 1; + if (loading || !items) return null; + + // Filter SourceReference and ISource objects correctly + const sourceReferences = items.filter( + (item): item is SourceReference => item.type !== 'ingest_source' + ); - if (!items || items.length === 0) return null; - for (let i = 0; i < items[items.length - 1].input_slot; i++) { - if (!items.some((source) => source.input_slot === i + 1)) { - firstEmptySlot = i + 1; - break; - } - } - for (let i = 0; i < items[items.length - 1].input_slot; i++) { - // console.log(`On input slot: ${i + 1}`); - // console.log(`Checking sources:`); - // console.log(tempItems); - tempItems.every((source) => { - if (source.input_slot === i + 1) { - // console.log(`Found source on input slot: ${i + 1}`); - // console.log(`Removing source "${source.name}" from sources list`); - tempItems = tempItems.filter((i) => i._id !== source._id); - // console.log(`Adding source "${source.name}" to grid`); - if (!productionSetup.isActive) { - gridItems.push( - - - setSelectingText(isSelecting) - } - /> - - ); - } else { - gridItems.push( - - setSelectingText(isSelecting) - } - /> - ); - } - return false; - } else { - // console.log(`No source found on input slot: ${i + 1}`); - // console.log(`Adding empty slot to grid`); - if (productionSetup.isActive) { - gridItems.push( - - ); - } + const isISource = (source: SourceReference | ISource): source is ISource => { + // Use properties unique to ISource to check the type + return 'src' in source; + }; + + const gridItems = items.map((source) => { + const isSource = isISource(source); + + return ( + + {isSource ? ( + setSelectingText(isSelecting)} + type={'ingest_source'} + /> + ) : ( + setSelectingText(isSelecting)} + type={source.type} + /> + )} + + ); + }); - return false; - } - }); - } return <>{gridItems}; } diff --git a/src/components/sourceListItem/SourceListItem.tsx b/src/components/sourceListItem/SourceListItem.tsx index a76ca05..c8b724e 100644 --- a/src/components/sourceListItem/SourceListItem.tsx +++ b/src/components/sourceListItem/SourceListItem.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Source, SourceWithId } from '../../interfaces/Source'; +import { Source, SourceReference, SourceWithId } from '../../interfaces/Source'; import { PreviewThumbnail } from './PreviewThumbnail'; import { getSourceThumbnail } from '../../utils/source'; import videoSettings from '../../utils/videoSettings'; @@ -95,7 +95,7 @@ function InventoryListItem({ : [] ); } - }, [source.audio_stream.audio_mapping]); + }, [source?.audio_stream.audio_mapping]); return (
  • +

    + {t('source.last_connected')}:{' '} + {new Date(source.lastConnected).toLocaleString()} +

    {t('source.ingest', { ingest: source.ingest_name diff --git a/src/components/startProduction/StartProductionButton.tsx b/src/components/startProduction/StartProductionButton.tsx index ad1f82b..aca65a4 100644 --- a/src/components/startProduction/StartProductionButton.tsx +++ b/src/components/startProduction/StartProductionButton.tsx @@ -85,7 +85,7 @@ export function StartProductionButton({ return; } let productionToStart: Production; - if (!production.production_settings.pipelines[0].multiview) { + if (!production.production_settings.pipelines[0].multiviews) { if (!multiviewPresets || multiviewPresets.length === 0) { toast.error(t('start_production_status.unexpected')); return; @@ -106,7 +106,7 @@ export function StartProductionButton({ ), { ...pipelineToUpdateMultiview, - multiview: { ...multiviewPresets[0], for_pipeline_idx: 0 } + multiviews: [{ ...multiviewPresets[0], for_pipeline_idx: 0 }] } ] } diff --git a/src/constants.ts b/src/constants.ts index 3627524..b44e32d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,11 +1,11 @@ /** - * The API version of the Agile Live API that is currently supported. + * The API version of the Ateliere Live API that is currently supported. * @constant */ -export const AGILE_API_VERSION = 'v3'; +export const LIVE_API_VERSION = 'v3'; /** - * The base path for the Agile Live API, where all other endpoints can be appended. + * The base path for the Ateliere Live API, where all other endpoints can be appended. * @constant */ -export const AGILE_BASE_API_PATH = '/api/' + AGILE_API_VERSION; +export const LIVE_BASE_API_PATH = '/api/' + LIVE_API_VERSION; diff --git a/src/hooks/controlPanels.ts b/src/hooks/controlPanels.ts index 5e4ebd7..21f8100 100644 --- a/src/hooks/controlPanels.ts +++ b/src/hooks/controlPanels.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { DataHook } from './types'; -import { ResourcesControlPanelResponse } from '../../types/agile-live'; +import { ResourcesControlPanelResponse } from '../../types/ateliere-live'; const ONE_MINUTE = 1000 * 60; export function useControlPanels(): [ ...DataHook, diff --git a/src/hooks/items/addSetupItem.ts b/src/hooks/items/addSetupItem.ts index ae354cb..b341c44 100644 --- a/src/hooks/items/addSetupItem.ts +++ b/src/hooks/items/addSetupItem.ts @@ -5,27 +5,29 @@ export function addSetupItem( source: SourceReference, productionSetup: Production ) { - const multiview = productionSetup.production_settings.pipelines[0].multiview; - if (!multiview) return; + const multiviews = + productionSetup.production_settings.pipelines[0].multiviews; + if (!multiviews || multiviews.length === 0) return; const updatedSetup = { ...productionSetup, sources: [ ...productionSetup.sources, { _id: source._id, + type: source.type, label: source.label, stream_uuids: source.stream_uuids, input_slot: source.input_slot } ].sort((a, b) => a.input_slot - b.input_slot) }; - return { ...updatedSetup, sources: [ ...productionSetup.sources, { _id: source._id, + type: source.type, label: source.label, stream_uuids: source.stream_uuids, input_slot: source.input_slot diff --git a/src/hooks/multiviews.ts b/src/hooks/multiviews.ts index 47a4e9a..009dcfb 100644 --- a/src/hooks/multiviews.ts +++ b/src/hooks/multiviews.ts @@ -2,25 +2,33 @@ import { useState } from 'react'; import { SourceReference } from '../interfaces/Source'; import { CallbackHook } from './types'; import { Production } from '../interfaces/production'; +import { MultiviewSettings } from '../interfaces/multiview'; export function useMultiviews(): CallbackHook< - (pipelineId: string, production: Production, source: SourceReference) => void + ( + pipelineId: string, + production: Production, + source: SourceReference, + singleMultiview: MultiviewSettings + ) => void > { const [loading, setLoading] = useState(true); const putMultiviewView = ( pipelineId: string, production: Production, - source: SourceReference + source: SourceReference, + singleMultiview: MultiviewSettings ) => { setLoading(true); - const multiview = production.production_settings.pipelines[0].multiview; - if (!multiview) throw 'no multiview'; - const rest = multiview.layout.views.filter( + if (!singleMultiview) throw 'no multiview'; + + const rest = singleMultiview.layout.views.filter( (v) => v.input_slot !== source.input_slot ); - const viewsToUpdate = multiview.layout.views.filter( + + const viewsToUpdate = singleMultiview.layout.views.filter( (v) => v.input_slot === source.input_slot ); console.log(viewsToUpdate); @@ -30,6 +38,7 @@ export function useMultiviews(): CallbackHook< label: source.label }; }); + const restWithLabels = rest.map((view) => { const sourceForView = production.sources.find( (s) => s.input_slot === view.input_slot @@ -40,15 +49,18 @@ export function useMultiviews(): CallbackHook< } return view; }); + if (!viewsToUpdate) { setLoading(false); return; } + const updatedMultiviewViews = [ ...restWithLabels, ...updatedViewsWithLabels ]; - fetch(`/api/manager/multiviews/${multiview.multiview_id}`, { + + fetch(`/api/manager/multiviews/${singleMultiview.multiview_id}`, { method: 'PUT', headers: [['x-api-key', `Bearer apisecretkey`]], body: JSON.stringify({ @@ -63,5 +75,6 @@ export function useMultiviews(): CallbackHook< }) .finally(() => setLoading(false)); }; + return [putMultiviewView, loading]; } diff --git a/src/hooks/pipelines.ts b/src/hooks/pipelines.ts index 34084d2..f9ef473 100644 --- a/src/hooks/pipelines.ts +++ b/src/hooks/pipelines.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { DataHook } from './types'; -import { ResourcesCompactPipelineResponse } from '../../types/agile-live'; +import { ResourcesCompactPipelineResponse } from '../../types/ateliere-live'; import { ManagerPipelineResponse } from '../interfaces/pipeline'; const ONE_MINUTE = 1000 * 60; diff --git a/src/hooks/productions.ts b/src/hooks/productions.ts index e3164fe..cdaf461 100644 --- a/src/hooks/productions.ts +++ b/src/hooks/productions.ts @@ -11,7 +11,8 @@ export function usePostProduction() { isActive: false, name, sources: [], - selectedPresetRef: undefined + html: [], + mediaplayers: [] }) }); if (response.ok) { diff --git a/src/hooks/sources/useSetSourceToPurge.tsx b/src/hooks/sources/useSetSourceToPurge.tsx new file mode 100644 index 0000000..75818cf --- /dev/null +++ b/src/hooks/sources/useSetSourceToPurge.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import { SourceWithId } from '../../interfaces/Source'; +import { CallbackHook } from '../types'; +import { Log } from '../../api/logger'; + +export function useSetSourceToPurge(): CallbackHook< + (source: SourceWithId) => void +> { + const [reloadList, setReloadList] = useState(false); + + const removeInventorySource = (source: SourceWithId) => { + if (source && source.status === 'gone') { + setReloadList(false); + + fetch(`/api/manager/inventory/${source._id}`, { + method: 'PUT', + // TODO: Implement api key + headers: [['x-api-key', `Bearer apisecretkey`]] + }) + .then((response) => { + if (!response.ok) { + setReloadList(true); + Log().error( + `Failed to set ${source.name} with id: ${source._id} to purge` + ); + } else { + console.log( + `${source.name} with id: ${source._id} is set to purge` + ); + } + setReloadList(true); + }) + .catch((e) => { + Log().error( + `Failed to set ${source.name} with id: ${source._id} to purge: ${e}` + ); + throw `Failed to set ${source.name} with id: ${source._id} to purge: ${e}`; + }); + } else { + setReloadList(false); + } + }; + return [removeInventorySource, reloadList]; +} diff --git a/src/hooks/sources/useSources.tsx b/src/hooks/sources/useSources.tsx index fb7ed1d..1b58418 100644 --- a/src/hooks/sources/useSources.tsx +++ b/src/hooks/sources/useSources.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { SourceWithId } from '../../interfaces/Source'; export function useSources( + deleteComplete?: boolean, updatedSource?: SourceWithId ): [Map, boolean] { const [sources, setSources] = useState>( @@ -10,7 +11,7 @@ export function useSources( const [loading, setLoading] = useState(true); useEffect(() => { - if (!updatedSource) { + if (!updatedSource || deleteComplete) { fetch('/api/manager/sources?mocked=false', { method: 'GET', // TODO: Implement api key @@ -33,6 +34,6 @@ export function useSources( } sources.set(updatedSource._id.toString(), updatedSource); setSources(new Map(sources)); - }, [updatedSource]); + }, [updatedSource, deleteComplete]); return [sources, loading]; } diff --git a/src/hooks/streams.ts b/src/hooks/streams.ts index e4341e1..c684bdd 100644 --- a/src/hooks/streams.ts +++ b/src/hooks/streams.ts @@ -17,6 +17,7 @@ export function useCreateStream(): CallbackHook< ) => Promise> > { const [loading, setLoading] = useState(false); + const createStream = async ( source: SourceWithId, production: Production, @@ -59,13 +60,18 @@ export function useDeleteStream(): CallbackHook< input_slot: number ): Promise> => { setLoading(true); + const pipelineUUID = production.production_settings.pipelines[0].pipeline_id; - const multiviewViews = - production.production_settings.pipelines[0].multiview?.layout.views; + + const multiviews = production.production_settings.pipelines[0].multiviews; + const multiviewViews = multiviews?.flatMap((singleMultiview) => { + return singleMultiview.layout.views; + }); const multiviewsToUpdate = multiviewViews?.filter( (v) => v.input_slot === input_slot ); + if (!multiviewsToUpdate || multiviewsToUpdate.length === 0) { const streamRequests = streamUuids.map((streamUuid) => { return fetch(`/api/manager/streams/${streamUuid}`, { @@ -98,7 +104,7 @@ export function useDeleteStream(): CallbackHook< }; } - const updatedMultiviews = multiviewsToUpdate.map((view) => { + const updatedMultiviews = multiviewsToUpdate?.map((view) => { return { ...view, label: view.label @@ -111,16 +117,19 @@ export function useDeleteStream(): CallbackHook< const sourceForView = production.sources.find( (s) => s.input_slot === v.input_slot ); + if (sourceForView) { return { ...v, label: sourceForView.label }; } + return v; }); + if ( !restWithLabels || !updatedMultiviews || updatedMultiviews.length === 0 || - !production.production_settings.pipelines[0].multiview?.layout + !multiviews?.some((singleMultiview) => singleMultiview.layout) ) { setLoading(false); return { @@ -131,13 +140,18 @@ export function useDeleteStream(): CallbackHook< const multiviewsWithLabels = [...restWithLabels, ...updatedMultiviews]; - const multiview = { - ...production.production_settings.pipelines[0].multiview, - layout: { - ...production.production_settings.pipelines[0].multiview?.layout, - views: multiviewsWithLabels - } - } satisfies MultiviewSettings; + const multiview: MultiviewSettings[] = multiviews.map( + (singleMultiview, index) => ({ + ...singleMultiview, + layout: { + ...singleMultiview.layout, + views: multiviewsWithLabels + }, + for_pipeline_idx: index, + multiviewId: index + 1 + }) + ); + const streamRequests = streamUuids.map((streamUuid) => { return fetch(`/api/manager/streams/${streamUuid}`, { method: 'DELETE', diff --git a/src/hooks/useDragableItems.ts b/src/hooks/useDragableItems.ts index 427ffbf..4cc3a03 100644 --- a/src/hooks/useDragableItems.ts +++ b/src/hooks/useDragableItems.ts @@ -9,57 +9,82 @@ export interface ISource extends SourceWithId { stream_uuids?: string[]; src: string; } + export function useDragableItems( sources: SourceReference[] -): [ISource[], (originId: string, destinationId: string) => void, boolean] { +): [ + (SourceReference | ISource)[], + (originId: string, destinationId: string) => void, + boolean +] { const [inventorySources, loading] = useSources(); - const [items, setItems] = useState( + const [items, setItems] = useState<(SourceReference | ISource)[]>( sources.flatMap((ref) => { const source = inventorySources.get(ref._id); if (!source) return []; return { ...source, + _id: ref._id, label: ref.label, input_slot: ref.input_slot, stream_uuids: ref.stream_uuids, - src: getSourceThumbnail(source) + src: getSourceThumbnail(source), + ingest_source_name: source.ingest_source_name, + ingest_name: source.ingest_name, + video_stream: source.video_stream, + audio_stream: source.audio_stream, + status: source.status, + type: source.type, + tags: source.tags, + name: source.name }; }) ); useEffect(() => { - setItems( - sources.flatMap((ref) => { - const source = inventorySources.get(ref._id); - if (!source) return []; - return { - ...source, - label: ref.label, - input_slot: ref.input_slot, - stream_uuids: ref.stream_uuids, - src: getSourceThumbnail(source) - }; - }) - ); + const updatedItems = sources.map((ref) => { + const source = inventorySources.get(ref._id); + if (!source) return { ...ref }; + return { + ...ref, + _id: ref._id, + status: source.status, + name: source.name, + type: source.type, + tags: source.tags, + ingest_name: source.ingest_name, + ingest_source_name: source.ingest_source_name, + ingest_type: source.ingest_type, + label: ref.label, + input_slot: ref.input_slot, + stream_uuids: ref.stream_uuids, + src: getSourceThumbnail(source), + video_stream: source.video_stream, + audio_stream: source.audio_stream, + lastConnected: source.lastConnected + }; + }); + setItems(updatedItems); }, [sources, inventorySources]); const moveItem = (originId: string, destinationId: string) => { - const originSource = items.find((i) => i._id.toString() === originId); + const originSource = items.find((item) => item._id.toString() === originId); const destinationSource = items.find( - (i) => i._id.toString() === destinationId + (item) => item._id.toString() === destinationId ); + if (!originSource || !destinationSource) return; - const originInputSlot = originSource.input_slot; - const destinationInputSlot = destinationSource.input_slot; - originSource.input_slot = destinationInputSlot; - destinationSource.input_slot = originInputSlot; - const updatedItems = [ - ...items.filter( - (i) => i._id !== originSource._id && i._id !== destinationSource._id - ), - originSource, - destinationSource - ].sort((a, b) => a.input_slot - b.input_slot); + + const updatedItems = items + .map((item) => { + if (item._id === originSource._id) + return { ...item, input_slot: destinationSource.input_slot }; + if (item._id === destinationSource._id) + return { ...item, input_slot: originSource.input_slot }; + return item; + }) + .sort((a, b) => a.input_slot - b.input_slot); + setItems(updatedItems); }; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ebb621d..596e25c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -45,7 +45,9 @@ export const en = { audio: 'Audio: {{audio}}', orig: 'Original Name: {{name}}', metadata: 'Source Metadata', - location_unknown: 'Unknown' + location_unknown: 'Unknown', + last_connected: 'Last connection', + input_slot: 'Input slot: {{input_slot}}' }, delete_source_status: { delete_stream: 'Delete stream', @@ -62,14 +64,17 @@ export const en = { }, production_configuration: 'Production Configuration', production: { - add_source: 'Add Source', + add_source: 'Add ingest', select_preset: 'Select Preset', clear_selection: 'Clear Selection', started: 'Production started: {{name}}', failed: 'Production start failed: {{name}}', stopped: 'Production stopped: {{name}}', stop_failed: 'Production stop failed: {{name}}', - missing_multiview: 'Missing multiview reference in selected preset' + missing_multiview: 'Missing multiview reference in selected preset', + source: 'Source', + add: 'Add', + add_other_source_type: 'Add other source type' }, create_new: 'Create New', default_prod_placeholder: 'My New Configuration', @@ -510,7 +515,10 @@ export const en = { locations: 'Location', active_sources: 'Active Sources', add: 'Add', - edit: 'Edit' + edit: 'Edit', + sort_by: 'Sort by', + no_sorting_applied: 'No sorting selected', + most_recent_connection: 'Most recent connection' }, clear: 'Clear', apply: 'Apply', @@ -603,7 +611,8 @@ export const en = { multiview_output_settings: 'Multiview output', select_multiview_preset: 'Preset', no_multiview_selected: 'No multiview selected', - no_multiview_found: 'No multiview found' + no_multiview_found: 'No multiview found', + no_port_selected: 'Unique port needed' }, error: { missing_sources_in_db: 'Missing sources, please restart production.', diff --git a/src/i18n/locales/sv.ts b/src/i18n/locales/sv.ts index d511d36..bf9f42c 100644 --- a/src/i18n/locales/sv.ts +++ b/src/i18n/locales/sv.ts @@ -47,7 +47,9 @@ export const sv = { audio: 'Ljud: {{audio}}', orig: 'Enhetsnamn: {{name}}', metadata: 'Käll-metadata', - location_unknown: 'Okänd' + location_unknown: 'Okänd', + last_connected: 'Senast uppkoppling', + input_slot: 'Ingång: {{input_slot}}' }, delete_source_status: { delete_stream: 'Radera ström', @@ -64,14 +66,17 @@ export const sv = { }, production_configuration: 'Produktionskonfiguration', production: { - add_source: 'Lägg till källa', + add_source: 'Lägg till ingång', select_preset: 'Välj produktionsmall', clear_selection: 'Rensa val', started: 'Produktion startad: {{name}}', failed: 'Start av produktion misslyckades: {{name}}', stopped: 'Produktion stoppad: {{name}}', stop_failed: 'Stopp av produktion misslyckades: {{name}}', - missing_multiview: 'Saknar referens till en multiview i valt preset' + missing_multiview: 'Saknar referens till en multiview i valt preset', + source: 'Källa', + add: 'Lägg till', + add_other_source_type: 'Lägg till annan källtyp' }, create_new: 'Skapa ny', default_prod_placeholder: 'Min Nya Konfiguration', @@ -512,7 +517,10 @@ export const sv = { locations: 'Plats', active_sources: 'Aktiva källor', add: 'Lägg till', - edit: 'Redigera' + edit: 'Redigera', + sort_by: 'Sortera på', + no_sorting_applied: 'Ingen sortering vald', + most_recent_connection: 'Senast anslutning' }, clear: 'Rensa', apply: 'Applicera', @@ -606,7 +614,8 @@ export const sv = { multiview_output_settings: 'Multiview utgång', no_multiview_selected: 'Ingen multiview vald', no_multiview_found: 'Hittade ingen multiview', - select_multiview_preset: 'Förinställningar' + select_multiview_preset: 'Förinställningar', + no_port_selected: 'Unik port krävs' }, error: { missing_sources_in_db: 'Källor saknas, var god starta om produktionen.', diff --git a/src/interfaces/Source.ts b/src/interfaces/Source.ts index 99e5704..88d0c48 100644 --- a/src/interfaces/Source.ts +++ b/src/interfaces/Source.ts @@ -1,6 +1,7 @@ import { ObjectId, WithId } from 'mongodb'; export type SourceType = 'camera' | 'graphics' | 'microphone'; export type SourceStatus = 'ready' | 'new' | 'gone'; +export type Type = 'ingest_source' | 'html' | 'mediaplayer'; export type VideoStream = { height?: number; width?: number; @@ -16,7 +17,7 @@ export type AudioStream = { export type Numbers = number | number[]; export interface Source { - _id?: ObjectId; + _id?: ObjectId | string; status: SourceStatus; name: string; type: SourceType; @@ -26,12 +27,15 @@ export interface Source { }; ingest_name: string; ingest_source_name: string; + ingest_type: string; video_stream: VideoStream; audio_stream: AudioStream; + lastConnected: Date; } export interface SourceReference { _id: string; + type: Type; label: string; stream_uuids?: string[]; input_slot: number; diff --git a/src/interfaces/pipeline.ts b/src/interfaces/pipeline.ts index ccf5e7f..b97f48f 100644 --- a/src/interfaces/pipeline.ts +++ b/src/interfaces/pipeline.ts @@ -1,4 +1,4 @@ -import { ResourcesPipelineResponse } from '../../types/agile-live'; +import { ResourcesPipelineResponse } from '../../types/ateliere-live'; import { MultiviewSettings } from './multiview'; import { WhepMultiview } from './whep'; @@ -62,7 +62,7 @@ export interface PipelineSettings { audio_mapping: string; program_output_port: number; // deprecated but kept for backward compatibility program_output: ProgramOutput[]; - multiview?: MultiviewSettings; + multiviews?: MultiviewSettings[]; interfaces: [ { commit_rate: number; diff --git a/src/middleware.ts b/src/middleware.ts index 7724e3b..cac0847 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -41,4 +41,4 @@ export default withAuth(function middleware(req) { } }); -export const config = { matcher: ['/', '/((?!api|images).*)/'] }; +export const config = { matcher: ['/', '/((?!api|images|html_input).*)/'] }; diff --git a/src/utils/checkApiConnections.ts b/src/utils/checkApiConnections.ts index d22076c..e62af19 100644 --- a/src/utils/checkApiConnections.ts +++ b/src/utils/checkApiConnections.ts @@ -5,7 +5,7 @@ interface IObject { interface Response { database?: IObject; - agileApi?: IObject; + liveApi?: IObject; message?: string; } diff --git a/src/utils/pipeline.ts b/src/utils/pipeline.ts index acbbef7..49ad88c 100644 --- a/src/utils/pipeline.ts +++ b/src/utils/pipeline.ts @@ -3,7 +3,7 @@ import { ResourcesPipelineResponse, ResourcesOutputActiveStreamMpegTsSrt -} from '../../types/agile-live'; +} from '../../types/ateliere-live'; import { SrtOutput } from '../interfaces/pipeline'; import { WhepMultiview } from '../interfaces/whep'; import { getWhepUrlForSRT } from './whep'; diff --git a/types/agile-live.d.ts b/types/ateliere-live.d.ts similarity index 100% rename from types/agile-live.d.ts rename to types/ateliere-live.d.ts diff --git a/update_gui_version.sh b/update_gui_version.sh index c4e49c6..9c8a917 100755 --- a/update_gui_version.sh +++ b/update_gui_version.sh @@ -1,2 +1,2 @@ #!/bin/bash -git describe --tags --dirty --match v* > gui-version.txt +git describe --always --tags --dirty --match v* > gui-version.txt