├── .env.template ├── .github └── workflows │ ├── backend.yaml │ ├── frontend.yaml │ └── infra.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cli.js ├── docs ├── LOCAL_DEVELOPMENT.md ├── USER_GUIDE.md └── architecture.jpg ├── index.html ├── infrastructure └── lambda │ ├── Pipfile │ ├── Pipfile.lock │ ├── api │ ├── __init__.py │ ├── app.py │ └── schemas.py │ ├── events │ └── index.py │ ├── pytest.ini │ └── tests │ ├── api │ ├── conftest.py │ └── test_app.py │ ├── conftest.py │ ├── events │ ├── conftest.py │ └── test_index.py │ └── stubs │ ├── describe_channel.json │ ├── list_channels.json │ ├── list_origin_endpoints.json │ └── query_table.json ├── package-lock.json ├── package.json ├── public ├── manifest.json └── robots.txt ├── src ├── App.jsx ├── components │ ├── AlertsTable.jsx │ ├── ChannelControls.jsx │ ├── ChannelSelector.jsx │ ├── ChannelStatus.jsx │ ├── ConfigTable.jsx │ ├── DiscoveredOutputsTable.jsx │ ├── GraphicForm.jsx │ ├── InputTable.jsx │ └── OutputSelector.jsx ├── constants.js ├── hooks │ ├── useAmplifyTheme.js │ ├── useApi.js │ ├── useChannels.js │ ├── useDiscoverOutputs.js │ └── usePagination.js ├── index.jsx ├── reportWebVitals.js ├── routes │ ├── Config.jsx │ └── Home.jsx └── setupTests.js ├── template.yaml └── vite.config.js /.env.template: -------------------------------------------------------------------------------- 1 | VITE_APP_AWS_REGION='' 2 | VITE_APP_API_GATEWAY_ENDPOINT='' 3 | VITE_APP_AWS_USER_POOL_ID='' 4 | VITE_APP_AWS_USER_POOL_WEB_CLIENT_ID='' 5 | -------------------------------------------------------------------------------- /.github/workflows/backend.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Backend tests 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: 10 | - opened 11 | - edited 12 | - synchronize 13 | jobs: 14 | test: 15 | name: Run backend tests 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 1 21 | # Setup Python 22 | - name: Set up Python 23 | id: setup-python 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: 3.9 27 | - name: Install pipenv 28 | run: | 29 | python -m pip install --upgrade pipenv 30 | # Pipenv cache 31 | - uses: actions/cache@v3 32 | with: 33 | path: ~/.local/share/virtualenvs 34 | key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-pipenv-${{ hashFiles('**/Pipfile.lock') }} 35 | # Install backend dependencies 36 | - name: Install python dependencies 37 | run: PIPENV_PIPFILE="$(pwd)/infrastructure/lambda/Pipfile" pipenv install --dev 38 | # Run tests 39 | - name: Run backend tests 40 | run: PIPENV_PIPFILE="$(pwd)/infrastructure/lambda/Pipfile" pipenv run pytest infrastructure/lambda 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/frontend.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Frontend build 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: 10 | - opened 11 | - edited 12 | - synchronize 13 | jobs: 14 | build: 15 | name: Run frontend build 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 1 21 | # Setup Node 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: 16 25 | # NPM cache 26 | - name: Get npm cache directory 27 | id: npm-cache-dir 28 | run: | 29 | echo "::set-output name=dir::$(npm config get cache)" 30 | - uses: actions/cache@v3 31 | id: npm-cache 32 | with: 33 | path: ${{ steps.npm-cache-dir.outputs.dir }} 34 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 35 | restore-keys: | 36 | ${{ runner.os }}-node- 37 | # Install UI dependencies 38 | - name: Install dependencies 39 | run: npm ci 40 | - name: Build app 41 | run: npm run-script build 42 | 43 | -------------------------------------------------------------------------------- /.github/workflows/infra.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: CFN Tests 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | types: 10 | - opened 11 | - edited 12 | - synchronize 13 | jobs: 14 | test: 15 | name: CFN static analysis tests 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 1 21 | # Setup Python 22 | - name: Set up Python 23 | id: setup-python 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: 3.9 27 | - name: Install cfn-lint 28 | run: pip install cfn-lint 29 | # Setup Ruby 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: '2.6' 33 | - name: Install cfn-nag 34 | run: gem install cfn-nag 35 | # Run Tests 36 | - name: Run cfn-nag 37 | run: cfn_nag_scan --input-path template.yaml 38 | - name: Run cfn-lint 39 | run: cfn-lint template.yaml 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | .vscode/* 27 | samconfig.toml 28 | .local/* 29 | *.pyc 30 | venv/ 31 | .idea/ 32 | .envrc 33 | .aws-sam 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | 4 | default_stages: 5 | - commit 6 | repos: 7 | # General 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v2.4.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - id: end-of-file-fixer 13 | - id: check-added-large-files 14 | args: [ '--maxkb=1000' ] 15 | - id: pretty-format-json 16 | args: [ --autofix, --indent, "2" ] 17 | exclude: /package-lock\.json$ 18 | - id: no-commit-to-branch 19 | args: [ "--branch", "main" ] 20 | 21 | # Secrets 22 | - repo: https://github.com/awslabs/git-secrets 23 | rev: 99d01d5 24 | hooks: 25 | - id: git-secrets 26 | 27 | # CloudFormation 28 | - repo: https://github.com/aws-cloudformation/cfn-python-lint 29 | rev: v0.70.1 30 | hooks: 31 | - id: cfn-python-lint 32 | name: AWS CloudFormation Linter 33 | files: template.yaml$ 34 | args: [ --ignore-checks=W2001, --ignore-checks=W3002, --ignore-checks=W3011 ] 35 | 36 | # JS 37 | - repo: https://github.com/pre-commit/mirrors-prettier 38 | rev: "v2.7.1" 39 | hooks: 40 | - id: prettier 41 | name: Prettier 42 | files: \.(js) 43 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS MediaLive Channel Orchestrator 2 | 3 | ## Contents 4 | 5 | - [Overview](#overview) 6 | - [High-level Diagram](#high-level-diagram) 7 | - [User Guide](#user-guide) 8 | - [Local Development](#local-development) 9 | 10 | ## Overview 11 | 12 | This repository contains sample code to deploy a web app that can be used to 13 | simplify the management of AWS MediaLive Channels. Supported functionality: 14 | - [x] Start/Stop Channels 15 | - [x] Input Switching 16 | - [x] Motion Graphics Overlays 17 | - [x] Channel Status 18 | - [x] Output confidence monitoring 19 | - [x] Media Package output autodiscovery 20 | - [x] Input alerting 21 | - [ ] Static Image Overlays 22 | - [ ] Channel Scheduling 23 | 24 | The CloudFormation template deploys the following resources: 25 | 26 | - An Amazon S3 bucket to store the web app files (uploaded separately) 27 | - An Amazon CloudFront distribution to serve the web app 28 | - An Amazon API Gateway with an associated AWS Lambda function which 29 | implements the endpoints exposed to manage your MediaLive channels 30 | - An Amazon Cognito user pool to provide authentication for the web app and API 31 | - An Amazon DynamoDB table for storing data associated with channels (graphics, output streams) 32 | - An AWS EventBridge rule for monitoring channel alerts with an associated 33 | AWS Lambda function for processing alerts 34 | 35 | **Note:** You are responsible for the cost of the AWS services used while running this solution. 36 | For full details, see the pricing pages for each AWS service you will be using in this sample. 37 | Prices are subject to change. 38 | 39 | ## High-level Diagram 40 | 41 | ![Solution architectures](docs/architecture.jpg) 42 | 43 | ## User Guide 44 | 45 | For details on how to configure and use the web application, consult the [user guide](./docs/USER_GUIDE.md). 46 | 47 | ## Local Development 48 | 49 | For details on how to run and extend the web app locally, consult the [local development guide](./docs/LOCAL_DEVELOPMENT.md). 50 | 51 | ## Security 52 | 53 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 54 | 55 | ## License 56 | 57 | This library is licensed under the MIT-0 License. See the LICENSE file. 58 | 59 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { Command } = require('commander'); 3 | const toml = require('toml'); 4 | const fs = require("fs/promises"); 5 | const { CloudFormationClient, DescribeStacksCommand } = require("@aws-sdk/client-cloudformation"); 6 | const program = new Command(); 7 | 8 | const REQUIRED_KEYS = [ 9 | "Region", 10 | "ApiEndpoint", 11 | "CognitoUserPoolID", 12 | "CognitoWebClientID", 13 | ] 14 | 15 | const writeConfigFile = (src, dest, configMap) => fs 16 | .readFile(src) 17 | .then(data => Object.entries(configMap).reduce((acc, [k, v]) => acc.replace(`<${k}>`,v), data.toString())) 18 | .then(data => fs.writeFile(dest, data)) 19 | ; 20 | 21 | const getOutputs = async (stackName, region=null) => { 22 | const clientConfig = {}; 23 | if (region) clientConfig.region = region; 24 | const client = new CloudFormationClient(clientConfig); 25 | const command = new DescribeStacksCommand({ 26 | StackName: stackName 27 | }); 28 | const res = await client.send(command); 29 | const stack = res.Stacks.find(i => i.StackName === stackName); 30 | if (!stack) throw new Error("Stack not found"); 31 | const outputMap = stack.Outputs.reduce((acc, next) => ({ 32 | ...acc, 33 | [next.OutputKey]: next.OutputValue 34 | }), {}); 35 | if (REQUIRED_KEYS.some(i => !(i in outputMap))) throw new Error(`Stack outputs missing required keys: ${JSON.stringify(outputMap)}`); 36 | return outputMap; 37 | } 38 | 39 | const getStackDetails =(configFile, env) => { 40 | return fs.readFile(configFile) 41 | .then(data => toml.parse(data)) 42 | .then(data => data[env].deploy.parameters) 43 | .then(data => ({ 44 | stackName: data.stack_name, 45 | region: data.region, 46 | })) 47 | } 48 | 49 | const generateConfig = (options) => { 50 | const { 51 | samConfig, 52 | samConfigEnv, 53 | debug 54 | } = options; 55 | 56 | if (debug) { 57 | console.debug(`SAM config file: ${samConfig}`) 58 | console.debug(`SAM config env: ${samConfigEnv}`) 59 | } 60 | 61 | getStackDetails(samConfig, samConfigEnv) 62 | .then(details => getOutputs(details.stackName, details.region)) 63 | .then(stackOutputs => writeConfigFile(".env.template", ".env.local", stackOutputs)) 64 | .then(() => console.log("Config generated successfully!")) 65 | .catch((err) => console.error(`Failed to generate config file: ${err}`)); 66 | } 67 | 68 | const echoOutput = (output) => (options) => { 69 | const { 70 | samConfig, 71 | samConfigEnv, 72 | debug 73 | } = options; 74 | 75 | if (debug) { 76 | console.debug(`SAM config file: ${samConfig}`) 77 | console.debug(`SAM config env: ${samConfigEnv}`) 78 | } 79 | 80 | getStackDetails(samConfig, samConfigEnv) 81 | .then(details => getOutputs(details.stackName, details.region)) 82 | .then(stackOutputs => console.log(stackOutputs[output])); 83 | } 84 | 85 | const echoBucket = echoOutput("WebUIBucket"); 86 | const echoUrl = echoOutput("WebUrl"); 87 | 88 | const makeCommand = (name, desc, handler) => program 89 | .command(name) 90 | .option('-c, --sam-config ', "The SAM config toml file generated when deploying the solution", "samconfig.toml") 91 | .option('-e, --sam-config-env ', "The SAM environment to use to generate the config file", "default") 92 | .option('-d, --debug', "Whether to debug") 93 | .description(desc) 94 | .action(handler) 95 | ; 96 | 97 | makeCommand("generate-config", "Generates a frontend configuration file for the AWS MediaLive Channel Orchestrator", generateConfig) 98 | makeCommand("echo-bucket", "Prints the frontend UI bucket name", echoBucket) 99 | makeCommand("echo-url", "Prints the deployed frontend URL", echoUrl) 100 | 101 | program.parse(process.argv); 102 | -------------------------------------------------------------------------------- /docs/LOCAL_DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Local Development 2 | 3 | ## Pre-requisites 4 | In addition to the pre-requisites mentioned in the [user guide](./USER_GUIDE.md#pre-requisites), 5 | for local development you will also need: 6 | 7 | - [Python 3.9+](https://www.python.org/downloads/) 8 | - (Optional) [pipenv](https://pipenv.pypa.io/en/latest/) 9 | 10 | ## Setup 11 | For local development, you will need to: 12 | 13 | 1. [Deploy the backend infrastructure](./USER_GUIDE.md#deployment) overriding 14 | the `AccessControlAllowOriginOverride` stack parameter with `http://localhost:5174` 15 | 2. Generate a config file 16 | 17 | ### Generate a Config File 18 | 19 | To generate the frontend config file (`.env.local`), run: 20 | 21 | ```bash 22 | npm run-script config 23 | ``` 24 | 25 | This will generate a `.env.local` file which contains the required configuration for the frontend 26 | application. To start the frontend locally, run: 27 | 28 | ```bash 29 | npm start 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/USER_GUIDE.md: -------------------------------------------------------------------------------- 1 | ## User Guide 2 | This section describes how to install, configure and use the AWS MediaLive Channel Orchestrator. 3 | 4 | - [Pre-requisites](#pre-requisites) 5 | - [Deployment](#deployment) 6 | - [Using the web app](#using-the-web-app) 7 | - [Uninstalling](#uninstalling) 8 | 9 | ## Pre-requisites 10 | To deploy this sample you will need to install: 11 | - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) 12 | - [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) 13 | - [NodeJS v14+](https://nodejs.org/en/download/) 14 | - (Windows only) WSL or another Unix-like shell (e.g. Cygwin or similar) 15 | - (Optional) [Git](https://git-scm.com/downloads) 16 | 17 | After installing these pre-requisites, clone this repository (or manually download it): 18 | 19 | ```bash 20 | git clone https://github.com/aws-samples/aws-medialive-channel-orchestrator.git 21 | ``` 22 | 23 | Then install the UI dependencies from the project root: 24 | 25 | ```bash 26 | cd aws-medialive-channel-orchestrator 27 | npm install 28 | ``` 29 | 30 | You should also already have AWS MediaLive Channels created and configured 31 | in the account in which this sample is being deployed. This sample only 32 | manages existing channels and does not create/delete channels nor does it 33 | configure the channel inputs, destinations etc. 34 | 35 | ## Deployment 36 | 37 | There are two stages to deploying: 38 | 1. Deploy the backend infrastructure 39 | 2. Deploy the frontend application 40 | 41 | ### Deploy the backend infrastructure 42 | 43 | Run the following command from the repository root to deploy the backend: 44 | 45 | ```bash 46 | sam deploy --guided --stack-name medialive-channel-orchestrator 47 | ``` 48 | 49 | Enter a stack name and region and either accept the default values for the stack 50 | parameters or override them as required. The parameters for the stack are: 51 | 52 | - **Stage:** (Default: dev) The environment stage (e.g. dev, test) for this deployment 53 | - **AccessLogsBucket:** (Default: "") The name of the bucket to use for storing the Web UI access logs. 54 | Leave blank to disable UI access logging. Ensure the provided bucket has the [appropriate 55 | permissions configured](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html#AccessLogsBucketAndFileOwnership) 56 | - **AccessControlAllowOriginOverride:** (Default: "") Allows overriding the origin from which the API 57 | can be called. If left blank, the API will only accept requests from the Web UI origin. 58 | - **DefaultUserEmail:** (Default: "") The email address you wish to setup as the initial user in the 59 | Cognito User Pool. This email address will be sent a temporary password which must be changed 60 | upon logging in for the first time. Leave blank to skip creating an initial user. 61 | - **CognitoAdvancedSecurity:** (Default: "OFF") The setting to use for [Cognito advanced security](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-advanced-security.html). 62 | Allowed values for this parameter are: OFF, AUDIT and ENFORCED. 63 | - **EnableBackups:** (Default: false) Whether to enable DynamoDB Point-in-Time Recovery for the 64 | DynamoDB tables. Enabling this feature will incur additional costs. 65 | - **AlertExpiry:** (Default 12) The number of hours to retain cleared alert messages. 66 | Specify 0 to retain the alerts indefinitely 67 | 68 | The stack will be deployed and config will be saved to `samconfig.toml`. 69 | You can omit the `--guided` and `--stack-name` CLI options for subsequent deployments. 70 | 71 | ### Deploy the frontend infrastructure 72 | 73 | To deploy the frontend infrastructure, run the following command: 74 | 75 | ```bash 76 | npm run-script deploy 77 | ``` 78 | 79 | The `deploy` command does the following: 80 | 1. Generates an `.env` file using the stack outputs from the stack 81 | created in step 1 82 | 2. Builds the frontend application 83 | 3. Copies the resulting files to the Web UI S3 Bucket created as part 84 | of the backend infrastructure. 85 | 86 | ## Using the Web App 87 | 88 | ### Accessing the Web App 89 | 90 | After completing the deployment steps UI is available at the `WebUrl` displayed in the stack outputs. 91 | If you need to obtain the deployed web URL at any point, run the following command 92 | from the project root: 93 | 94 | ```bash 95 | npm run-script echo-ui-url 96 | ``` 97 | 98 | The application sets up a Cognito User Pool for user management and (optionally) 99 | creates a default user. To add additional users, use the Cognito Console to manage 100 | the user pool for the application as described in the [Cognito docs](https://docs.aws.amazon.com/cognito/latest/developerguide/managing-users.html). 101 | The user pool ID is given in the stack outputs. 102 | 103 | When accessing the web app URL, you will be prompted to login, after which 104 | the application homepage will be displayed. 105 | 106 | ### Channel Controls 107 | 108 | The channels that exist in your account will be displayed in the **Channel Selector** dropdown. 109 | Use this selector to change which channel you are controlling. The status of the currently selected 110 | channel is displayed in the **Channel Controls** section. You can start and stop a channel using 111 | the **Start Channel** and **Stop Channel** buttons. 112 | 113 | ### Input Management 114 | 115 | All inputs which are attached to the currently selected channel will be displayed 116 | in the Inputs table along with a field indicating whether the input is the active input 117 | attachment for the channel pipeline. Inputs can also be [prepared](https://docs.aws.amazon.com/medialive/latest/ug/feature-prepare-input.html) 118 | using the **Prepare** button, and [input switching](https://docs.aws.amazon.com/medialive/latest/ug/scheduled-input-switching.html) 119 | can be performed using the **Switch** button. The prepare and switch buttons for an input 120 | are asynchronous operations that add an input prepare and input switch respectively 121 | with an immediate start to the channel schedule. 122 | 123 | ### Output Confidence Monitoring 124 | 125 | The web app displays a video player which will play the "outputs" configured for the 126 | solution to facilitate output confidence monitoring. To configure the visible outputs, 127 | choose the **Config** page from the navbar then choose the channel for which you wish 128 | to specify outputs, and select the **Outputs** tab. Choose the **Add New** button 129 | and provide a logical name and the stream url (e.g. a HLS index/playlist url) for 130 | the outputs you wish to monitor. Once added these outputs will be stored in a DynamoDB 131 | table and displayed on the web app homepage. This allows an operator to view the live 132 | output whilst making changes to the channel state, inputs or graphics. 133 | 134 | When configuring outputs, the web app will automatically discover and display the URLs for 135 | any AWS MediaPackage destinations associated with the selected channel. 136 | 137 | ### Channel Alerts 138 | 139 | Any alerts received relating to a channel will be displayed in the **Channel Alerts** 140 | section. Where an alert is active it will have a status of SET. Once an alert is 141 | cleared, the status will change to CLEARED and the alert will be removed altogether 142 | once it has been in the CLEARED state for the "alert expiry" period configured when 143 | deploying the application. 144 | 145 | ### Motion Graphics Overlays 146 | 147 | **This section applies only to motion graphics overlays. [Static image overlays](https://docs.aws.amazon.com/medialive/latest/ug/working-with-image-overlay.html) are not currently supported.** 148 | 149 | The web app provides controls for users to insert motion graphics overlays into a channel. A user can 150 | select a graphic from the graphic selector and choose **Insert** which will add an immediate 151 | activate motion graphic action to the channel schedule. Choose the **Stop Graphics** button 152 | to remove all graphics by inserting an immediate deactivate motion graphics action to the 153 | channel schedule. Motion graphics controls for a channel will only be enabled if the channel has 154 | motion graphics enabled. 155 | 156 | To configure the available graphics for a channel, choose the **Config** page from the navbar 157 | then choose the channel for which you wish to specify outputs, and select the **Graphics** tab. 158 | Choose the **Add New** button and provide a logical name and the **fixed** url for the graphic. 159 | Once added these outputs will be stored in a DynamoDB table and available in the graphic selector. 160 | 161 | For more info on motion graphics with AWS MediaLive, [consult the docs](https://docs.aws.amazon.com/medialive/latest/ug/feature-mgi.html). 162 | 163 | ## Uninstalling 164 | 165 | To delete this project from your AWS account, you will need to first delete all objects 166 | from the web UI S3 bucket. Once done, delete the application CloudFormation stack. To 167 | delete the stack via AWS Console: 168 | 169 | 1. Open the CloudFormation Console Page and choose the solution stack, then choose "Delete" 170 | 2. Once the confirmation modal appears, choose "Delete stack". 171 | 3. Wait for the CloudFormation stack to finish updating. Completion is indicated when the "Stack status" is "DELETE_COMPLETE". 172 | 173 | To delete a stack via the AWS CLI [consult the documentation](https://docs.aws.amazon.com/cli/latest/reference/cloudformation/delete-stack.html). 174 | -------------------------------------------------------------------------------- /docs/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-medialive-channel-orchestrator/cd60fdd85dfdbfb4d34568d86611db71874f2dec/docs/architecture.jpg -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 25 | AWS MediaLive Channel Orchestrator 26 | 27 | 28 | 29 |
30 | 31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /infrastructure/lambda/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | 8 | [dev-packages] 9 | boto3 = "*" 10 | aws-lambda-powertools = "*" 11 | aws-xray-sdk = "*" 12 | pytest = "*" 13 | fastjsonschema = "*" 14 | 15 | [requires] 16 | python_version = "3.9" 17 | -------------------------------------------------------------------------------- /infrastructure/lambda/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "d7258c1f621746c5ac4172daadad4caa31cb3216c08da163af6736cacd2a4709" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": {}, 19 | "develop": { 20 | "attrs": { 21 | "hashes": [ 22 | "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", 23 | "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" 24 | ], 25 | "markers": "python_version >= '3.7'", 26 | "version": "==23.1.0" 27 | }, 28 | "aws-lambda-powertools": { 29 | "hashes": [ 30 | "sha256:11015bf54d5a69a2e2b6638809894a00c3f5a5a07718fbf97fcd3157ce5ea87d", 31 | "sha256:ec465d72a3f6525dac4ac000fde5d8b4cf341ac07fe6c75675b4e5bd8995a639" 32 | ], 33 | "index": "pypi", 34 | "markers": "python_full_version >= '3.7.4' and python_full_version < '4.0.0'", 35 | "version": "==2.1.0" 36 | }, 37 | "aws-xray-sdk": { 38 | "hashes": [ 39 | "sha256:7551e81a796e1a5471ebe84844c40e8edf7c218db33506d046fec61f7495eda4", 40 | "sha256:9b14924fd0628cf92936055864655354003f0b1acc3e1c3ffde6403d0799dd7a" 41 | ], 42 | "index": "pypi", 43 | "version": "==2.10.0" 44 | }, 45 | "boto3": { 46 | "hashes": [ 47 | "sha256:7e871c481f88e5b2fc6ac16eb190c95de21efb43ab2d959beacf8b7b096b11d2", 48 | "sha256:b81e4aa16891eac7532ce6cc9eb690a8d2e0ceea3bcf44b5c5a1309c2500d35f" 49 | ], 50 | "index": "pypi", 51 | "markers": "python_version >= '3.7'", 52 | "version": "==1.26.3" 53 | }, 54 | "botocore": { 55 | "hashes": [ 56 | "sha256:6f35d59e230095aed7cd747604fe248fa384bebb7d09549077892f936a8ca3df", 57 | "sha256:988b948be685006b43c4bbd8f5c0cb93e77c66deb70561994e0c5b31b5a67210" 58 | ], 59 | "markers": "python_version >= '3.7'", 60 | "version": "==1.29.165" 61 | }, 62 | "exceptiongroup": { 63 | "hashes": [ 64 | "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", 65 | "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" 66 | ], 67 | "markers": "python_version < '3.11'", 68 | "version": "==1.1.3" 69 | }, 70 | "fastjsonschema": { 71 | "hashes": [ 72 | "sha256:01e366f25d9047816fe3d288cbfc3e10541daf0af2044763f3d0ade42476da18", 73 | "sha256:21f918e8d9a1a4ba9c22e09574ba72267a6762d47822db9add95f6454e51cc1c" 74 | ], 75 | "index": "pypi", 76 | "version": "==2.16.2" 77 | }, 78 | "iniconfig": { 79 | "hashes": [ 80 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 81 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 82 | ], 83 | "markers": "python_version >= '3.7'", 84 | "version": "==2.0.0" 85 | }, 86 | "jmespath": { 87 | "hashes": [ 88 | "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", 89 | "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe" 90 | ], 91 | "markers": "python_version >= '3.7'", 92 | "version": "==1.0.1" 93 | }, 94 | "packaging": { 95 | "hashes": [ 96 | "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", 97 | "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" 98 | ], 99 | "markers": "python_version >= '3.7'", 100 | "version": "==23.2" 101 | }, 102 | "pluggy": { 103 | "hashes": [ 104 | "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12", 105 | "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7" 106 | ], 107 | "markers": "python_version >= '3.8'", 108 | "version": "==1.3.0" 109 | }, 110 | "pytest": { 111 | "hashes": [ 112 | "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71", 113 | "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59" 114 | ], 115 | "index": "pypi", 116 | "markers": "python_version >= '3.7'", 117 | "version": "==7.2.0" 118 | }, 119 | "python-dateutil": { 120 | "hashes": [ 121 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 122 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 123 | ], 124 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 125 | "version": "==2.8.2" 126 | }, 127 | "s3transfer": { 128 | "hashes": [ 129 | "sha256:b014be3a8a2aab98cfe1abc7229cc5a9a0cf05eb9c1f2b86b230fd8df3f78084", 130 | "sha256:cab66d3380cca3e70939ef2255d01cd8aece6a4907a9528740f668c4b0611861" 131 | ], 132 | "markers": "python_version >= '3.7'", 133 | "version": "==0.6.2" 134 | }, 135 | "six": { 136 | "hashes": [ 137 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 138 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 139 | ], 140 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 141 | "version": "==1.16.0" 142 | }, 143 | "tomli": { 144 | "hashes": [ 145 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 146 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 147 | ], 148 | "markers": "python_version < '3.11'", 149 | "version": "==2.0.1" 150 | }, 151 | "urllib3": { 152 | "hashes": [ 153 | "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3", 154 | "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429" 155 | ], 156 | "index": "pypi", 157 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 158 | "version": "==1.26.19" 159 | }, 160 | "wrapt": { 161 | "hashes": [ 162 | "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0", 163 | "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420", 164 | "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a", 165 | "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c", 166 | "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079", 167 | "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923", 168 | "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f", 169 | "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1", 170 | "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8", 171 | "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86", 172 | "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0", 173 | "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364", 174 | "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e", 175 | "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c", 176 | "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e", 177 | "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c", 178 | "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727", 179 | "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff", 180 | "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e", 181 | "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29", 182 | "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7", 183 | "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72", 184 | "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475", 185 | "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a", 186 | "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317", 187 | "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2", 188 | "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd", 189 | "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640", 190 | "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98", 191 | "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248", 192 | "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e", 193 | "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d", 194 | "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec", 195 | "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1", 196 | "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e", 197 | "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9", 198 | "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92", 199 | "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb", 200 | "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094", 201 | "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46", 202 | "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29", 203 | "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd", 204 | "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705", 205 | "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8", 206 | "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975", 207 | "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb", 208 | "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e", 209 | "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b", 210 | "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418", 211 | "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019", 212 | "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1", 213 | "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba", 214 | "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6", 215 | "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2", 216 | "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3", 217 | "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7", 218 | "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752", 219 | "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416", 220 | "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f", 221 | "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1", 222 | "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc", 223 | "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145", 224 | "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee", 225 | "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a", 226 | "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7", 227 | "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b", 228 | "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653", 229 | "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0", 230 | "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90", 231 | "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29", 232 | "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6", 233 | "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034", 234 | "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09", 235 | "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559", 236 | "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639" 237 | ], 238 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 239 | "version": "==1.15.0" 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /infrastructure/lambda/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-medialive-channel-orchestrator/cd60fdd85dfdbfb4d34568d86611db71874f2dec/infrastructure/lambda/api/__init__.py -------------------------------------------------------------------------------- /infrastructure/lambda/api/app.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | import schemas 4 | import uuid 5 | import json 6 | from os import getenv 7 | 8 | import boto3 9 | from boto3.dynamodb.conditions import Key 10 | from urllib.parse import unquote, urlparse 11 | 12 | from aws_lambda_powertools import Logger, Tracer 13 | from aws_lambda_powertools.event_handler import ( 14 | APIGatewayRestResolver, 15 | CORSConfig, 16 | Response, 17 | content_types, 18 | ) 19 | from aws_lambda_powertools.logging import correlation_paths 20 | from aws_lambda_powertools.utilities.validation import validate 21 | from aws_lambda_powertools.event_handler.exceptions import ( 22 | NotFoundError, BadRequestError) 23 | from aws_lambda_powertools.utilities.validation import SchemaValidationError 24 | 25 | cors_origin = getenv("ALLOW_ORIGIN", "*") 26 | tracer = Tracer() 27 | logger = Logger(service="APP") 28 | cors_config = CORSConfig(allow_origin=cors_origin, max_age=300) 29 | app = APIGatewayRestResolver(cors=cors_config) 30 | 31 | session = boto3.Session() 32 | medialive = session.client('medialive') 33 | mediapackage = session.client('mediapackage') 34 | dynamodb = session.resource('dynamodb') 35 | table = dynamodb.Table(getenv('CHANNEL_TABLE')) 36 | 37 | 38 | @app.exception_handler(medialive.exceptions.NotFoundException) 39 | def handle_not_found(ex): 40 | metadata = {"path": app.current_event.path, 41 | "query_strings": app.current_event.query_string_parameters} 42 | logger.error(f"Not found: {ex}", extra=metadata) 43 | 44 | return Response(status_code=404) 45 | 46 | 47 | @app.exception_handler(medialive.exceptions.UnprocessableEntityException) 48 | def handle_unprocessable_entity(ex): 49 | return handle_invalid_request(ex) 50 | 51 | 52 | @app.exception_handler(SchemaValidationError) 53 | def handle_schema_validation(ex): 54 | return handle_invalid_request(ex) 55 | 56 | 57 | @app.exception_handler(BadRequestError) 58 | def handle_bad_request(ex): 59 | return handle_invalid_request(ex) 60 | 61 | 62 | def handle_invalid_request(ex): 63 | metadata = {"path": app.current_event.path, 64 | "query_strings": app.current_event.query_string_parameters} 65 | logger.error(f"Bad input: {ex}", extra=metadata) 66 | 67 | return Response( 68 | status_code=400, 69 | content_type=content_types.APPLICATION_JSON, 70 | body=json.dumps({"message": "The request parameters were invalid."}) 71 | ) 72 | 73 | 74 | @app.exception_handler(medialive.exceptions.TooManyRequestsException) 75 | def handle_throttle(ex): 76 | metadata = {"path": app.current_event.path, 77 | "query_strings": app.current_event.query_string_parameters} 78 | logger.error(f"Throttled request: {ex}", extra=metadata) 79 | 80 | return Response( 81 | status_code=429, 82 | content_type=content_types.APPLICATION_JSON, 83 | body=json.dumps( 84 | {"message": "The request was throttled, please try again."}) 85 | ) 86 | 87 | 88 | @app.exception_handler(Exception) 89 | def handle_other_errors(ex): 90 | metadata = {"path": app.current_event.path, 91 | "query_strings": app.current_event.query_string_parameters} 92 | logger.error(f"General exception: {ex}", extra=metadata) 93 | 94 | return Response( 95 | status_code=502, 96 | content_type=content_types.APPLICATION_JSON, 97 | body=json.dumps({"message": "Something went wrong, please try again."}) 98 | ) 99 | 100 | 101 | @app.get("/channels") 102 | @tracer.capture_method 103 | def get_channels(): 104 | ml_channels = _get_ml_channels() 105 | 106 | return { 107 | 'Channels': ml_channels, 108 | } 109 | 110 | 111 | @app.get("/channels/") 112 | @tracer.capture_method 113 | def get_channel_data(channel_id): 114 | # Validate channel exists 115 | channel = medialive.describe_channel(ChannelId=channel_id) 116 | 117 | response = table.query( 118 | KeyConditionExpression=Key('ChannelId').eq(channel_id), 119 | ) 120 | 121 | outputs, graphics, alerts = [], [], [] 122 | invalid = {"SK", "ChannelId", "ExpiresAt"} 123 | 124 | for item in response['Items']: 125 | if item['SK'].startswith('GRAPHIC#'): 126 | graphics.append({x: item[x] for x in item if x not in invalid}) 127 | elif item['SK'].startswith('OUTPUT#'): 128 | outputs.append({x: item[x] for x in item if x not in invalid}) 129 | elif item['SK'].startswith('ALERT#'): 130 | alerts.append({x: item[x] for x in item if x not in invalid}) 131 | else: 132 | logger.warning('Unidentified channel entry', item) 133 | 134 | return { 135 | 'ChannelId': channel_id, 136 | 'Outputs': outputs, 137 | 'Graphics': graphics, 138 | 'Alerts': alerts, 139 | 'GraphicsEnabled': channel.get("EncoderSettings", {}) 140 | .get("MotionGraphicsConfiguration", {}) 141 | .get("MotionGraphicsInsertion") == "ENABLED" 142 | } 143 | 144 | 145 | @app.put("/channels//status/") 146 | @tracer.capture_method 147 | def put_channel_status(channel_id, status): 148 | status = status.lower() 149 | if status not in ['start', 'stop']: 150 | raise BadRequestError(f'Given status: {status} is not a valid status.') 151 | 152 | return _update_channel_status(channel_id, status) 153 | 154 | 155 | @app.put("/channels//activeinput/") 156 | @tracer.capture_method 157 | def put_active_input(channel_id, input_name): 158 | 159 | action_name = str(uuid.uuid4()) 160 | action = { 161 | 'ActionName': action_name, 162 | 'ScheduleActionSettings': { 163 | 'InputSwitchSettings': { 164 | 'InputAttachmentNameReference': unquote(input_name), 165 | } 166 | }, 167 | 'ScheduleActionStartSettings': { 168 | 'ImmediateModeScheduleActionStartSettings': {} 169 | } 170 | } 171 | _write_schedule_item(channel_id, action) 172 | return { 173 | "ActiveInput": input_name 174 | } 175 | 176 | 177 | @app.post("/channels//prepareinput/") 178 | @tracer.capture_method 179 | def post_input_prepare(channel_id, input_name): 180 | 181 | action_name = str(uuid.uuid4()) 182 | action = { 183 | 'ActionName': action_name, 184 | 'ScheduleActionSettings': { 185 | 'InputPrepareSettings': { 186 | 'InputAttachmentNameReference': unquote(input_name) 187 | } 188 | }, 189 | 'ScheduleActionStartSettings': { 190 | 'ImmediateModeScheduleActionStartSettings': {} 191 | } 192 | } 193 | _write_schedule_item(channel_id, action) 194 | 195 | 196 | @app.post("/channels//graphics//start") 197 | @tracer.capture_method 198 | def post_start_graphics(channel_id, graphic_id): 199 | validate(event=app.current_event.json_body, 200 | schema=schemas.start_graphics_body) 201 | 202 | duration = app.current_event.json_body.get('Duration', 0) 203 | 204 | item = table.get_item( 205 | Key={ 206 | 'ChannelId': channel_id, 207 | 'SK': f'GRAPHIC#{graphic_id}' 208 | } 209 | ).get('Item') 210 | 211 | if not item: 212 | raise NotFoundError 213 | 214 | action_name = str(uuid.uuid4()) 215 | action = { 216 | 'ActionName': action_name, 217 | 'ScheduleActionSettings': { 218 | 'MotionGraphicsImageActivateSettings': { 219 | 'Duration': duration, 220 | 'Url': item['Url'], 221 | }, 222 | }, 223 | 'ScheduleActionStartSettings': { 224 | 'ImmediateModeScheduleActionStartSettings': {} 225 | } 226 | } 227 | _write_schedule_item(channel_id, action) 228 | 229 | 230 | @app.post("/channels//graphics/stop") 231 | @tracer.capture_method 232 | def post_stop_graphics(channel_id): 233 | 234 | action_name = str(uuid.uuid4()) 235 | action = { 236 | 'ActionName': action_name, 237 | 'ScheduleActionSettings': { 238 | 'MotionGraphicsImageDeactivateSettings': {} 239 | }, 240 | 'ScheduleActionStartSettings': { 241 | 'ImmediateModeScheduleActionStartSettings': {} 242 | } 243 | } 244 | _write_schedule_item(channel_id, action) 245 | 246 | 247 | @app.post("/channels//graphics") 248 | @tracer.capture_method 249 | def post_graphic(channel_id): 250 | validate(event=app.current_event.json_body, 251 | schema=schemas.post_graphic_body) 252 | 253 | # Validate channel exists 254 | medialive.describe_channel(ChannelId=channel_id) 255 | 256 | new_id = str(uuid.uuid4()) 257 | item = { 258 | 'ChannelId': channel_id, 259 | 'SK': f'GRAPHIC#{new_id}', 260 | 'Id': new_id 261 | } 262 | 263 | item.update(app.current_event.json_body) 264 | 265 | table.put_item(Item=item) 266 | 267 | return {key: value for key, value in item.items() if key != 'SK'} 268 | 269 | 270 | @app.delete("/channels//graphics/") 271 | @tracer.capture_method 272 | def delete_graphic(channel_id, graphic_id): 273 | 274 | table.delete_item( 275 | Key={ 276 | 'ChannelId': channel_id, 277 | 'SK': f'GRAPHIC#{graphic_id}' 278 | } 279 | ) 280 | 281 | return Response(status_code=204) 282 | 283 | 284 | @app.post("/channels//outputs") 285 | @tracer.capture_method 286 | def post_output(channel_id): 287 | validate(event=app.current_event.json_body, 288 | schema=schemas.post_output_body) 289 | 290 | # Validate channel exists 291 | medialive.describe_channel(ChannelId=channel_id) 292 | 293 | new_id = str(uuid.uuid4()) 294 | item = { 295 | 'ChannelId': channel_id, 296 | 'SK': f'OUTPUT#{new_id}', 297 | 'Id': new_id 298 | } 299 | 300 | item.update(app.current_event.json_body) 301 | 302 | table.put_item(Item=item) 303 | 304 | return {key: value for key, value in item.items() if key != 'SK'} 305 | 306 | 307 | @app.delete("/channels//outputs/") 308 | @tracer.capture_method 309 | def delete_output(channel_id, output_id): 310 | 311 | table.delete_item( 312 | Key={ 313 | 'ChannelId': channel_id, 314 | 'SK': f'OUTPUT#{output_id}' 315 | } 316 | ) 317 | 318 | return Response(status_code=204) 319 | 320 | 321 | @app.get("/channels//outputs/discover") 322 | @tracer.capture_method 323 | def discover_outputs(channel_id): 324 | channel = medialive.describe_channel(ChannelId=channel_id) 325 | mp_channel_ids = [ 326 | i["ChannelId"] for i in 327 | _get_channel_mp_destinations(channel) 328 | ] 329 | results = [] 330 | paginator = mediapackage.get_paginator('list_origin_endpoints') 331 | page_iterator = paginator.paginate() 332 | 333 | for page in page_iterator: 334 | for endpoint in page['OriginEndpoints']: 335 | if endpoint["ChannelId"] in mp_channel_ids and any(i in endpoint for i in ["HlsPackage", "DashPackage"]): 336 | results.append({ 337 | "Name": endpoint["Id"], 338 | "Url": endpoint["Url"], 339 | "Type": "MEDIA_PACKAGE", 340 | "OutputMetadata": { 341 | "ChannelId": endpoint["ChannelId"], 342 | } 343 | }) 344 | return {"Outputs": results} 345 | 346 | 347 | @tracer.capture_method 348 | def _get_channel_mp_destinations(channel_data): 349 | dests = channel_data.get("Destinations", []) 350 | return list(chain(*[i.get("MediaPackageSettings") for i in dests if len(i.get("MediaPackageSettings", [])) > 0])) 351 | 352 | 353 | @tracer.capture_method 354 | def _write_schedule_item(channel_id, schedule_action): 355 | response = medialive.batch_update_schedule( 356 | ChannelId=channel_id, 357 | Creates={ 358 | 'ScheduleActions': [ 359 | schedule_action 360 | ] 361 | } 362 | ) 363 | return response 364 | 365 | 366 | @tracer.capture_method 367 | def _get_ml_channels(): 368 | results = [] 369 | paginator = medialive.get_paginator('list_channels') 370 | page_iterator = paginator.paginate() 371 | 372 | for page in page_iterator: 373 | for channel in page['Channels']: 374 | channel_description = medialive.describe_channel( 375 | ChannelId=channel['Id']) 376 | 377 | results.append({ 378 | 'Id': channel['Id'], 379 | 'State': channel['State'], 380 | 'Name': channel.get('Name', ''), 381 | 'InputAttachments': [ 382 | { 383 | "Id": i["InputId"], 384 | "Name": i["InputAttachmentName"], 385 | "Active": _is_input_active(i, channel_description.get('PipelineDetails', [])), 386 | } 387 | for i in channel.get('InputAttachments', []) 388 | ], 389 | }) 390 | 391 | return results 392 | 393 | 394 | @tracer.capture_method 395 | def _is_input_active(input_attachment, pipeline_details): 396 | if len(pipeline_details) == 0: 397 | return False 398 | return pipeline_details[0]["ActiveInputAttachmentName"] == input_attachment["InputAttachmentName"] 399 | 400 | 401 | @tracer.capture_method 402 | def _update_channel_status(channel_id, status): 403 | if status == 'stop': 404 | response = medialive.stop_channel(ChannelId=channel_id) 405 | else: 406 | response = medialive.start_channel(ChannelId=channel_id) 407 | 408 | return {'Id': response['Id'], 'State': response['State']} 409 | 410 | 411 | def is_valid_url(url, qualifying=('scheme', 'netloc')): 412 | tokens = urlparse(url) 413 | return all([getattr(tokens, qualifying_attr) 414 | for qualifying_attr in qualifying]) 415 | 416 | 417 | @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) 418 | @tracer.capture_lambda_handler 419 | def lambda_handler(event, context): 420 | return app.resolve(event, context) 421 | -------------------------------------------------------------------------------- /infrastructure/lambda/api/schemas.py: -------------------------------------------------------------------------------- 1 | start_graphics_body = { 2 | "type": "object", 3 | "properties": { 4 | "Duration": { 5 | "type": "integer" 6 | } 7 | }, 8 | "additionalProperties": False 9 | } 10 | 11 | post_output_body = { 12 | "type": "object", 13 | "properties": { 14 | "Url": { 15 | "type": "string" 16 | }, 17 | "Name": { 18 | "type": "string" 19 | } 20 | }, 21 | "required": ["Url", "Name"], 22 | "additionalProperties": False 23 | } 24 | 25 | post_graphic_body = post_output_body 26 | -------------------------------------------------------------------------------- /infrastructure/lambda/events/index.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from os import getenv 3 | 4 | import boto3 5 | from aws_lambda_powertools.utilities.data_classes import event_source, EventBridgeEvent 6 | 7 | from aws_lambda_powertools import Logger, Tracer 8 | from aws_lambda_powertools.logging import correlation_paths 9 | from aws_lambda_powertools.utilities.typing import LambdaContext 10 | 11 | tracer = Tracer() 12 | logger = Logger(service="EVENTS") 13 | 14 | session = boto3.Session() 15 | dynamodb = session.resource('dynamodb') 16 | table = dynamodb.Table(getenv('CHANNEL_TABLE')) 17 | alert_expiry = int(getenv('ALERT_EXPIRY', 12)) 18 | 19 | 20 | def process_event(event: EventBridgeEvent): 21 | try: 22 | alarm_id = event.detail["alarm_id"] 23 | alarm_state = event.detail["alarm_state"].upper() 24 | message = event.detail["message"] 25 | channel_id = event.detail["channel_arn"].split(":")[-1] 26 | logger.info(f"Received {alarm_state} alert for channel {channel_id}: {message} ({alarm_id})") 27 | event_datetime = datetime.strptime(event.time, '%Y-%m-%dT%H:%M:%S%z') 28 | event_ts = int(event_datetime.timestamp()) 29 | params = { 30 | "ChannelId": channel_id, 31 | "SK": f"ALERT#{alarm_id}", 32 | "Id": alarm_id, 33 | "State": alarm_state, 34 | "Message": message, 35 | "AlertedAt": event_ts 36 | } 37 | if alarm_state == "CLEARED" and alert_expiry > 0: 38 | event_datetime = datetime.strptime(event.time, '%Y-%m-%dT%H:%M:%S%z') 39 | expiry = event_datetime + timedelta(hours=alert_expiry) 40 | params["ExpiresAt"] = int(expiry.timestamp()) 41 | table.put_item( 42 | Item=params, 43 | ConditionExpression='attribute_not_exists(#SK) OR #AlertedAt < :AlertedAt', 44 | ExpressionAttributeNames={"#SK": "SK", "#AlertedAt": "AlertedAt"}, 45 | ExpressionAttributeValues={':AlertedAt': event_ts} 46 | ) 47 | except KeyError as err: 48 | logger.error(f"Invalid event received: {event.detail}") 49 | raise err 50 | except dynamodb.meta.client.exceptions.ConditionalCheckFailedException: 51 | logger.warning("Skipping older alert") 52 | except dynamodb.meta.client.exceptions.ClientError as err: 53 | logger.error(f"Unable to write event to DynamoDB: {event.detail}") 54 | raise err 55 | 56 | 57 | @logger.inject_lambda_context(correlation_id_path=correlation_paths.EVENT_BRIDGE) 58 | @event_source(data_class=EventBridgeEvent) 59 | def lambda_handler(event: EventBridgeEvent, context: LambdaContext): 60 | if "Alert" in event.detail_type and "MediaLive" in event.detail_type: 61 | process_event(event) 62 | -------------------------------------------------------------------------------- /infrastructure/lambda/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = api events tests 3 | python_files = test_*.py 4 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/api/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest import mock 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def generate_ml_resp(client, pagination_tuples): 11 | def side_effect(op_name): 12 | resp = next((result for call, result in pagination_tuples if call == op_name), None) 13 | if resp is not None: 14 | client.paginate.return_value = iter([resp]) 15 | else: 16 | client.paginate.return_value = iter([]) 17 | return client 18 | return side_effect 19 | 20 | 21 | @pytest.fixture() 22 | def medialive_client(list_channels_stub, describe_channel_stub): 23 | mock_ml = MagicMock() 24 | mock_ml.get_paginator.side_effect = generate_ml_resp(mock_ml, [("list_channels", list_channels_stub)]) 25 | mock_ml.describe_channel.return_value = describe_channel_stub 26 | 27 | with mock.patch("app.medialive", mock_ml): 28 | yield mock_ml 29 | 30 | 31 | @pytest.fixture() 32 | def mediapackage_client(list_origin_endpoints_stub): 33 | mock_mp = MagicMock() 34 | mock_mp.get_paginator.side_effect = generate_ml_resp(mock_mp, [("list_origin_endpoints", list_origin_endpoints_stub)]) 35 | 36 | with mock.patch("app.mediapackage", mock_mp): 37 | yield mock_mp 38 | 39 | 40 | @pytest.fixture() 41 | def ddb_table(query_table_stub): 42 | mock_ddb = MagicMock() 43 | mock_ddb.query.return_value = query_table_stub 44 | with mock.patch("app.table", mock_ddb): 45 | yield mock_ddb 46 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/api/test_app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest import mock 3 | 4 | import pytest 5 | from importlib import import_module 6 | 7 | from aws_lambda_powertools.utilities.validation import SchemaValidationError 8 | from aws_lambda_powertools.event_handler.exceptions import ( 9 | NotFoundError, BadRequestError) 10 | from boto3.dynamodb.conditions import Key 11 | 12 | logger = logging.getLogger(__name__) 13 | ENV_REGION_KEY = 'AWS_DEFAULT_REGION' 14 | 15 | channel_id = 'foo' 16 | 17 | 18 | @pytest.fixture() 19 | def app(): 20 | yield import_module('app') 21 | 22 | 23 | @pytest.fixture(autouse=True) 24 | def reset_event(app): 25 | app.app.current_event = None 26 | 27 | 28 | @pytest.mark.usefixtures('medialive_client', 'mediapackage_client', 'ddb_table', 'app') 29 | class TestApp: 30 | def test_it_returns_channels(self, list_channels_stub, medialive_client, app): 31 | result = app.get_channels() 32 | assert result == { 33 | "Channels": [ 34 | { 35 | "Id": "abcdef01234567890", 36 | "State": "IDLE", 37 | "Name": "Channel 1", 38 | "InputAttachments": [ 39 | { 40 | "Id": "021345abcdef6789", 41 | "Name": "Input 1", 42 | "Active": True, 43 | }, 44 | { 45 | "Id": "021345abcdef6789", 46 | "Name": "Input 2", 47 | "Active": False, 48 | }, 49 | ], 50 | } 51 | ] 52 | } 53 | 54 | def test_it_starts_channels(self, medialive_client, app): 55 | status = "start" 56 | 57 | medialive_client.start_channel.return_value = { 58 | "Id": channel_id, 59 | "State": "Starting" 60 | } 61 | result = app.put_channel_status(channel_id, status) 62 | assert result == { 63 | "Id": channel_id, 64 | "State": "Starting", 65 | } 66 | medialive_client.start_channel.assert_called_with(ChannelId=channel_id) 67 | 68 | def test_it_stops_channels(self, medialive_client, app): 69 | status = "stop" 70 | 71 | medialive_client.stop_channel.return_value = { 72 | "Id": channel_id, 73 | "State": "Stopping" 74 | } 75 | result = app.put_channel_status(channel_id, status) 76 | assert result == { 77 | "Id": channel_id, 78 | "State": "Stopping", 79 | } 80 | medialive_client.stop_channel.assert_called_with(ChannelId=channel_id) 81 | 82 | def test_it_throws_for_invalid_channel_state(self, app): 83 | status = "invalid" 84 | 85 | with pytest.raises(BadRequestError): 86 | app.put_channel_status(channel_id, status) 87 | 88 | def test_it_switches_inputs(self, medialive_client, app): 89 | input_name = 'Input 2' 90 | 91 | result = app.put_active_input(channel_id, input_name) 92 | assert result == { 93 | "ActiveInput": input_name 94 | } 95 | medialive_client.batch_update_schedule.assert_called_with(**{ 96 | 'ChannelId': channel_id, 97 | 'Creates': { 98 | 'ScheduleActions': [{ 99 | 'ActionName': mock.ANY, 100 | 'ScheduleActionSettings': { 101 | 'InputSwitchSettings': { 102 | 'InputAttachmentNameReference': input_name 103 | } 104 | }, 105 | 'ScheduleActionStartSettings': {'ImmediateModeScheduleActionStartSettings': {}} 106 | }] 107 | } 108 | }) 109 | 110 | def test_it_prepares_inputs(self, medialive_client, app): 111 | input_name = 'Input 2' 112 | 113 | result = app.post_input_prepare(channel_id, input_name) 114 | assert result is None 115 | medialive_client.batch_update_schedule.assert_called_with(**{ 116 | 'ChannelId': channel_id, 117 | 'Creates': { 118 | 'ScheduleActions': [{ 119 | 'ActionName': mock.ANY, 120 | 'ScheduleActionSettings': { 121 | 'InputPrepareSettings': { 122 | 'InputAttachmentNameReference': input_name 123 | } 124 | }, 125 | 'ScheduleActionStartSettings': {'ImmediateModeScheduleActionStartSettings': {}} 126 | }] 127 | } 128 | }) 129 | 130 | def test_it_starts_motion_graphics_without_duration(self, ddb_table, medialive_client, app): 131 | graphic_id = '101' 132 | duration = 0 133 | url = 'https://example.com/output/12345678?aspect=16x9' 134 | 135 | app.app.current_event = mock.MagicMock() 136 | app.app.current_event.json_body = {} 137 | 138 | ddb_table.get_item.return_value = { 139 | 'Item': { 140 | "ChannelId": channel_id, 141 | "Id": graphic_id, 142 | "Name": "Overlay 1", 143 | "SK": f'GRAPHIC#{graphic_id}', 144 | "Url": url 145 | } 146 | } 147 | 148 | result = app.post_start_graphics(channel_id, graphic_id) 149 | assert result is None 150 | medialive_client.batch_update_schedule.assert_called_with(**{ 151 | 'ChannelId': channel_id, 152 | 'Creates': { 153 | 'ScheduleActions': [{ 154 | 'ActionName': mock.ANY, 155 | 'ScheduleActionSettings': { 156 | 'MotionGraphicsImageActivateSettings': { 157 | 'Duration': duration, 158 | 'Url': url, 159 | }, 160 | }, 161 | 'ScheduleActionStartSettings': { 162 | 'ImmediateModeScheduleActionStartSettings': {} 163 | } 164 | }] 165 | } 166 | }) 167 | 168 | def test_it_starts_motion_graphics_with_duration(self, ddb_table, medialive_client, app): 169 | graphic_id = '101' 170 | duration = 123 171 | url = 'https://example.com/output/12345678?aspect=16x9' 172 | 173 | app.app.current_event = mock.MagicMock() 174 | app.app.current_event.json_body = {'Duration': 123} 175 | 176 | ddb_table.get_item.return_value = { 177 | 'Item': { 178 | "ChannelId": channel_id, 179 | "Id": graphic_id, 180 | "Name": "Overlay 1", 181 | "SK": f'GRAPHIC#{graphic_id}', 182 | "Url": url 183 | } 184 | } 185 | 186 | result = app.post_start_graphics(channel_id, graphic_id) 187 | assert result is None 188 | medialive_client.batch_update_schedule.assert_called_with(**{ 189 | 'ChannelId': channel_id, 190 | 'Creates': { 191 | 'ScheduleActions': [{ 192 | 'ActionName': mock.ANY, 193 | 'ScheduleActionSettings': { 194 | 'MotionGraphicsImageActivateSettings': { 195 | 'Duration': duration, 196 | 'Url': url, 197 | }, 198 | }, 199 | 'ScheduleActionStartSettings': { 200 | 'ImmediateModeScheduleActionStartSettings': {} 201 | } 202 | }] 203 | } 204 | }) 205 | 206 | def test_it_throws_if_graphic_not_found(self, ddb_table, medialive_client, app): 207 | graphic_id = '101' 208 | 209 | app.app.current_event = mock.MagicMock() 210 | app.app.current_event.json_body = {'Duration': 123} 211 | 212 | ddb_table.get_item.return_value = {} 213 | 214 | with pytest.raises(NotFoundError): 215 | app.post_start_graphics(channel_id, graphic_id) 216 | 217 | def test_it_stops_motion_graphics(self, medialive_client, app): 218 | result = app.post_stop_graphics(channel_id) 219 | assert result is None 220 | medialive_client.batch_update_schedule.assert_called_with(**{ 221 | 'ChannelId': channel_id, 222 | 'Creates': { 223 | 'ScheduleActions': [{ 224 | 'ActionName': mock.ANY, 225 | 'ScheduleActionSettings': { 226 | 'MotionGraphicsImageDeactivateSettings': {} 227 | }, 228 | 'ScheduleActionStartSettings': { 229 | 'ImmediateModeScheduleActionStartSettings': {} 230 | } 231 | }] 232 | }}) 233 | 234 | def test_it_returns_channel_data(self, ddb_table, medialive_client, app): 235 | result = app.get_channel_data(channel_id) 236 | assert result == { 237 | 'ChannelId': channel_id, 238 | 'Outputs': [ 239 | { 240 | 'Id': '001', 241 | 'Url': 'https://www.example.com/embed/12345678', 242 | 'Name': 'Example' 243 | } 244 | ], 245 | 'Graphics': [ 246 | { 247 | 'Id': '101', 248 | 'Url': 'https://example.com/output/12345678?aspect=16x9', 249 | 'Name': 'Overlay 1' 250 | } 251 | ], 252 | 'Alerts': [ 253 | { 254 | 'Id': '100', 255 | 'Message': 'foobar', 256 | 'AlertedAt': 0, 257 | 'State': 'CLEARED' 258 | } 259 | ], 260 | 'GraphicsEnabled': True, 261 | } 262 | ddb_table.query.assert_called_with(**{ 263 | 'KeyConditionExpression': Key('ChannelId').eq(channel_id) 264 | }) 265 | medialive_client.describe_channel.assert_called_with(**{ 266 | 'ChannelId': channel_id 267 | }) 268 | 269 | def test_it_returns_disabled_graphics(self, ddb_table, describe_channel_stub, medialive_client, app): 270 | describe_channel_stub["EncoderSettings"] = { 271 | "MotionGraphicsConfiguration": { 272 | "MotionGraphicsInsertion": "DISABLED", 273 | "MotionGraphicsSettings": { 274 | "HtmlMotionGraphicsSettings": {} 275 | } 276 | } 277 | } 278 | 279 | result = app.get_channel_data(channel_id) 280 | assert result == { 281 | 'ChannelId': channel_id, 282 | 'Outputs': [ 283 | { 284 | 'Id': '001', 285 | 'Url': 'https://www.example.com/embed/12345678', 286 | 'Name': 'Example' 287 | } 288 | ], 289 | 'Graphics': [ 290 | { 291 | 'Id': '101', 292 | 'Url': 'https://example.com/output/12345678?aspect=16x9', 293 | 'Name': 'Overlay 1' 294 | } 295 | ], 296 | 'Alerts': [ 297 | { 298 | 'Id': '100', 299 | 'Message': 'foobar', 300 | 'AlertedAt': 0, 301 | 'State': 'CLEARED' 302 | } 303 | ], 304 | 'GraphicsEnabled': False, 305 | } 306 | ddb_table.query.assert_called_with(**{ 307 | 'KeyConditionExpression': Key('ChannelId').eq(channel_id) 308 | }) 309 | medialive_client.describe_channel.assert_called_with(**{ 310 | 'ChannelId': channel_id 311 | }) 312 | 313 | def test_it_deletes_outputs(self, ddb_table, app): 314 | output_id = '123' 315 | 316 | result = app.delete_output(channel_id, output_id) 317 | assert result.status_code == 204 318 | ddb_table.delete_item.assert_called_with(Key={ 319 | 'ChannelId': channel_id, 320 | 'SK': f'OUTPUT#{output_id}' 321 | }) 322 | 323 | def test_it_deletes_graphics(self, ddb_table, app): 324 | graphic_id = '123' 325 | 326 | result = app.delete_graphic(channel_id, graphic_id) 327 | assert result.status_code == 204 328 | ddb_table.delete_item.assert_called_with(Key={ 329 | 'ChannelId': channel_id, 330 | 'SK': f'GRAPHIC#{graphic_id}' 331 | }) 332 | 333 | def test_it_posts_output(self, ddb_table, medialive_client, app): 334 | url = 'https://example.com/output/12345678?aspect=16x9' 335 | name = 'My Output' 336 | 337 | app.app.current_event = mock.MagicMock() 338 | app.app.current_event.json_body = { 339 | 'Url': url, 340 | 'Name': name 341 | } 342 | 343 | result = app.post_output(channel_id) 344 | assert result == { 345 | 'ChannelId': channel_id, 346 | 'Id': mock.ANY, 347 | 'Name': name, 348 | 'Url': url 349 | } 350 | 351 | result_id = result['Id'] 352 | ddb_table.put_item.assert_called_with(Item={ 353 | 'ChannelId': channel_id, 354 | 'SK': f'OUTPUT#{result_id}', 355 | 'Id': result_id, 356 | 'Url': url, 357 | 'Name': name 358 | }) 359 | medialive_client.describe_channel.assert_called_with(**{ 360 | 'ChannelId': channel_id 361 | }) 362 | 363 | def test_it_posts_graphic(self, ddb_table, medialive_client, app): 364 | url = 'https://example.com/output/12345678?aspect=16x9' 365 | name = 'My Graphic' 366 | 367 | app.app.current_event = mock.MagicMock() 368 | app.app.current_event.json_body = { 369 | 'Url': url, 370 | 'Name': name 371 | } 372 | 373 | result = app.post_graphic(channel_id) 374 | assert result == { 375 | 'ChannelId': channel_id, 376 | 'Id': mock.ANY, 377 | 'Name': name, 378 | 'Url': url 379 | } 380 | 381 | result_id = result['Id'] 382 | ddb_table.put_item.assert_called_with(Item={ 383 | 'ChannelId': channel_id, 384 | 'SK': f'GRAPHIC#{result_id}', 385 | 'Id': result_id, 386 | 'Url': url, 387 | 'Name': name 388 | }) 389 | medialive_client.describe_channel.assert_called_with(**{ 390 | 'ChannelId': channel_id 391 | }) 392 | 393 | def test_it_throws_with_invalid_data(self, app): 394 | app.app.current_event = mock.MagicMock() 395 | app.app.current_event.json_body = {} 396 | 397 | with pytest.raises(SchemaValidationError): 398 | app.post_output(channel_id) 399 | 400 | with pytest.raises(SchemaValidationError): 401 | app.post_graphic(channel_id) 402 | 403 | def test_it_throws_with_additional_keys(self, app): 404 | url = 'https://example.com/output/12345678?aspect=16x9' 405 | name = 'My Graphic' 406 | app.app.current_event = mock.MagicMock() 407 | app.app.current_event.json_body = { 408 | 'Url': url, 409 | 'Name': name, 410 | 'Invalid': 'key' 411 | } 412 | 413 | with pytest.raises(SchemaValidationError): 414 | app.post_output(channel_id) 415 | 416 | with pytest.raises(SchemaValidationError): 417 | app.post_graphic(channel_id) 418 | 419 | def test_it_discovers_mediapackage_outputs(self, app): 420 | result = app.discover_outputs(channel_id) 421 | assert result == { 422 | 'Outputs': [{ 423 | 'Name': 'testDASHEndpoint', 424 | 'OutputMetadata': {'ChannelId': 'test'}, 425 | 'Type': 'MEDIA_PACKAGE', 426 | 'Url': 'https://abcdef01234567890.mediapackage.eu-west-1.amazonaws.com/out/v1/abcdef01234567890/index.mpd' 427 | }, { 428 | 'Name': 'devHLSEndpoint', 429 | 'OutputMetadata': {'ChannelId': 'test'}, 430 | 'Type': 'MEDIA_PACKAGE', 431 | 'Url': 'https://abcdef01234567891.mediapackage.eu-west-1.amazonaws.com/out/v1/abcdef01234567891/index.m3u8' 432 | }] 433 | } 434 | 435 | def test_it_ignores_irrelevant_mediapackage_origin_endpoints(self, describe_channel_stub, app): 436 | describe_channel_stub["Destinations"] = [ 437 | { 438 | "Id": "mediapackage-destination", 439 | "MediaPackageSettings": [ 440 | { 441 | "ChannelId": "irrelevant" 442 | } 443 | ], 444 | "Settings": [] 445 | } 446 | ] 447 | 448 | result = app.discover_outputs(channel_id) 449 | assert result == { 450 | 'Outputs': [] 451 | } 452 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from pathlib import Path 5 | from unittest import mock 6 | 7 | import pytest 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @pytest.fixture(autouse=True, scope="module") 13 | def env_vars(): 14 | with mock.patch.dict(os.environ, {"AWS_DEFAULT_REGION": "us-east-1", "CHANNEL_TABLE": "CHANNEL_TABLE", "ALERT_EXPIRY": "1"}): 15 | yield 16 | 17 | 18 | stubs_dir = Path(__file__).parent / "stubs" 19 | 20 | 21 | def load_stub(stub_file): 22 | with open(stubs_dir / stub_file) as f: 23 | return json.load(f) 24 | 25 | 26 | @pytest.fixture(scope="function") 27 | def describe_channel_stub(): 28 | return load_stub("describe_channel.json") 29 | 30 | 31 | @pytest.fixture(scope="function") 32 | def list_channels_stub(): 33 | return load_stub("list_channels.json") 34 | 35 | 36 | @pytest.fixture(scope="function") 37 | def list_origin_endpoints_stub(): 38 | return load_stub("list_origin_endpoints.json") 39 | 40 | 41 | @pytest.fixture(scope="function") 42 | def describe_channel_stub(): 43 | return load_stub("describe_channel.json") 44 | 45 | 46 | @pytest.fixture(scope="function") 47 | def list_origin_endpoints_stub(): 48 | return load_stub("list_origin_endpoints.json") 49 | 50 | 51 | @pytest.fixture(scope="function") 52 | def query_table_stub(): 53 | return load_stub("query_table.json") 54 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/events/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest import mock 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @pytest.fixture() 11 | def ddb_table(query_table_stub): 12 | mock_ddb = MagicMock() 13 | mock_ddb.put_item.return_value = {} 14 | with mock.patch("index.table", mock_ddb): 15 | yield mock_ddb 16 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/events/test_index.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | from importlib import import_module 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @pytest.fixture() 12 | def app(): 13 | yield import_module('index') 14 | 15 | 16 | @pytest.fixture() 17 | def event_stub(): 18 | yield { 19 | "version": "0", 20 | "id": "faff4b2f-4ec9-53b6-ecd0-a53370b1c088", 21 | "detail-type": "MediaLive Channel Alert", 22 | "source": "aws.medialive", 23 | "account": "123456789012", 24 | "time": "1970-01-01T00:00:00Z", 25 | "region": "us-east-1", 26 | "resources": ["arn:aws:medialive:us-east-1:123456789012:channel:123456"], 27 | "detail": { 28 | "alarm_id": "foobar", 29 | "alert_type": "Stopped Receiving UDP Input", 30 | "alarm_state": "set", 31 | "channel_arn": "arn:aws:medialive:us-east-1:123456789012:channel:123456", 32 | "message": "Stopped receiving network data on [rtp://:5000]", 33 | "pipeline": "1" 34 | } 35 | } 36 | 37 | 38 | @pytest.mark.usefixtures('ddb_table', 'app') 39 | class TestEvents: 40 | def test_it_sets_alerts(self, event_stub, ddb_table, app): 41 | app.lambda_handler(event_stub, MagicMock()) 42 | ddb_table.put_item.assert_called_with( 43 | Item={ 44 | 'ChannelId': '123456', 45 | 'SK': 'ALERT#foobar', 46 | 'Id': 'foobar', 47 | 'State': 'SET', 48 | 'Message': 'Stopped receiving network data on [rtp://:5000]', 49 | 'AlertedAt': 0 50 | }, 51 | ConditionExpression='attribute_not_exists(#SK) OR #AlertedAt < :AlertedAt', 52 | ExpressionAttributeNames={'#SK': 'SK', '#AlertedAt': 'AlertedAt'}, 53 | ExpressionAttributeValues={':AlertedAt': 0} 54 | ) 55 | 56 | def test_it_clears_alerts_with_ttl(self, event_stub, ddb_table, app): 57 | event_stub["detail"]["alarm_state"] = "cleared" 58 | app.lambda_handler(event_stub, MagicMock()) 59 | ddb_table.put_item.assert_called_with( 60 | Item={ 61 | 'ChannelId': '123456', 62 | 'SK': 'ALERT#foobar', 63 | 'Id': 'foobar', 64 | 'State': 'CLEARED', 65 | 'Message': 'Stopped receiving network data on [rtp://:5000]', 66 | 'AlertedAt': 0, 67 | 'ExpiresAt': 3600 68 | }, 69 | ConditionExpression='attribute_not_exists(#SK) OR #AlertedAt < :AlertedAt', 70 | ExpressionAttributeNames={'#SK': 'SK', '#AlertedAt': 'AlertedAt'}, 71 | ExpressionAttributeValues={':AlertedAt': 0} 72 | ) 73 | 74 | def test_it_omits_expires_at_when_ttl_configured_as_zero(self, event_stub, ddb_table, app): 75 | event_stub["detail"]["alarm_state"] = "cleared" 76 | app.alert_expiry = 0 77 | app.lambda_handler(event_stub, MagicMock()) 78 | ddb_table.put_item.assert_called_with( 79 | Item={ 80 | 'ChannelId': '123456', 81 | 'SK': 'ALERT#foobar', 82 | 'Id': 'foobar', 83 | 'State': 'CLEARED', 84 | 'Message': 'Stopped receiving network data on [rtp://:5000]', 85 | 'AlertedAt': 0 86 | }, 87 | ConditionExpression='attribute_not_exists(#SK) OR #AlertedAt < :AlertedAt', 88 | ExpressionAttributeNames={'#SK': 'SK', '#AlertedAt': 'AlertedAt'}, 89 | ExpressionAttributeValues={':AlertedAt': 0} 90 | ) 91 | 92 | def test_it_handles_conditional_errors(self, event_stub, ddb_table, app): 93 | ddb_table.put_item.side_effect = [ddb_table.meta.client.ConditionalCheckFailedException()] 94 | app.lambda_handler(event_stub, MagicMock()) 95 | 96 | def test_it_throws_for_malformed_events(self, event_stub, ddb_table, app): 97 | with pytest.raises(KeyError): 98 | app.lambda_handler({}, MagicMock()) 99 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/stubs/describe_channel.json: -------------------------------------------------------------------------------- 1 | { 2 | "Arn": "arn:aws:medialive:eu-west-1:111122223333:channel:abcdef01234567890", 3 | "ChannelClass": "SINGLE_PIPELINE", 4 | "Destinations": [ 5 | { 6 | "Id": "mediapackage-destination", 7 | "MediaPackageSettings": [ 8 | { 9 | "ChannelId": "test" 10 | } 11 | ], 12 | "Settings": [] 13 | } 14 | ], 15 | "EncoderSettings": { 16 | "MotionGraphicsConfiguration": { 17 | "MotionGraphicsInsertion": "ENABLED", 18 | "MotionGraphicsSettings": { 19 | "HtmlMotionGraphicsSettings": {} 20 | } 21 | } 22 | }, 23 | "Id": "abcdef01234567890", 24 | "PipelineDetails": [ 25 | { 26 | "ActiveInputAttachmentName": "Input 1", 27 | "ActiveInputSwitchActionName": "a1b2c3d4-5678-90ab-cdef-EXAMPLE11111", 28 | "PipelineId": "0" 29 | } 30 | ], 31 | "Tags": { 32 | "GenericStream": "https://example.com/c, https://example.com/d,invalid" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/stubs/list_channels.json: -------------------------------------------------------------------------------- 1 | { 2 | "Channels": [ 3 | { 4 | "Arn": "arn:aws:medialive:eu-west-1:111122223333:channel:abcdef01234567890", 5 | "ChannelClass": "SINGLE_PIPELINE", 6 | "Destinations": [ 7 | { 8 | "Id": "mediapackage-destination", 9 | "MediaPackageSettings": [ 10 | { 11 | "ChannelId": "abcdef01234567890" 12 | } 13 | ], 14 | "Settings": [] 15 | } 16 | ], 17 | "Id": "abcdef01234567890", 18 | "InputAttachments": [ 19 | { 20 | "InputAttachmentName": "Input 1", 21 | "InputId": "021345abcdef6789" 22 | }, 23 | { 24 | "InputAttachmentName": "Input 2", 25 | "InputId": "021345abcdef6789" 26 | } 27 | ], 28 | "Name": "Channel 1", 29 | "RoleArn": "arn:aws:iam::111122223333:role/role", 30 | "State": "IDLE", 31 | "Tags": {} 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/stubs/list_origin_endpoints.json: -------------------------------------------------------------------------------- 1 | { 2 | "OriginEndpoints": [ 3 | { 4 | "Arn": "arn:aws:mediapackage:eu-west-1:111122223333:origin_endpoints/abcdef01234567890", 5 | "ChannelId": "test", 6 | "DashPackage": { 7 | "AdTriggers": [ 8 | "SPLICE_INSERT", 9 | "PROVIDER_ADVERTISEMENT", 10 | "DISTRIBUTOR_ADVERTISEMENT", 11 | "PROVIDER_PLACEMENT_OPPORTUNITY", 12 | "DISTRIBUTOR_PLACEMENT_OPPORTUNITY" 13 | ], 14 | "AdsOnDeliveryRestrictions": "RESTRICTED", 15 | "ManifestLayout": "FULL", 16 | "ManifestWindowSeconds": 60, 17 | "MinBufferTimeSeconds": 30, 18 | "MinUpdatePeriodSeconds": 15, 19 | "PeriodTriggers": [], 20 | "Profile": "NONE", 21 | "SegmentDurationSeconds": 2, 22 | "SegmentTemplateFormat": "NUMBER_WITH_TIMELINE", 23 | "StreamSelection": { 24 | "MaxVideoBitsPerSecond": 2147483647, 25 | "MinVideoBitsPerSecond": 0, 26 | "StreamOrder": "ORIGINAL" 27 | }, 28 | "SuggestedPresentationDelaySeconds": 25, 29 | "UtcTiming": "NONE" 30 | }, 31 | "Description": "DASH endpoint created by MediaLive for channel test", 32 | "Id": "testDASHEndpoint", 33 | "ManifestName": "index", 34 | "Origination": "ALLOW", 35 | "StartoverWindowSeconds": 0, 36 | "Tags": { 37 | "MSAM-Diagram": "test", 38 | "MSAM-Tile": "test", 39 | "MediaLive-Workflow": "test" 40 | }, 41 | "TimeDelaySeconds": 0, 42 | "Url": "https://abcdef01234567890.mediapackage.eu-west-1.amazonaws.com/out/v1/abcdef01234567890/index.mpd", 43 | "Whitelist": [] 44 | }, 45 | { 46 | "Arn": "arn:aws:mediapackage:eu-west-1:111122223333:origin_endpoints/abcdef01234567891", 47 | "ChannelId": "test", 48 | "Description": "HLS endpoint created by MediaLive for channel test", 49 | "HlsPackage": { 50 | "AdMarkers": "NONE", 51 | "AdTriggers": [ 52 | "SPLICE_INSERT", 53 | "PROVIDER_ADVERTISEMENT", 54 | "DISTRIBUTOR_ADVERTISEMENT", 55 | "PROVIDER_PLACEMENT_OPPORTUNITY", 56 | "DISTRIBUTOR_PLACEMENT_OPPORTUNITY" 57 | ], 58 | "AdsOnDeliveryRestrictions": "RESTRICTED", 59 | "IncludeDvbSubtitles": false, 60 | "IncludeIframeOnlyStream": false, 61 | "PlaylistType": "EVENT", 62 | "PlaylistWindowSeconds": 60, 63 | "ProgramDateTimeIntervalSeconds": 0, 64 | "SegmentDurationSeconds": 6, 65 | "StreamSelection": { 66 | "MaxVideoBitsPerSecond": 2147483647, 67 | "MinVideoBitsPerSecond": 0, 68 | "StreamOrder": "ORIGINAL" 69 | }, 70 | "UseAudioRenditionGroup": false 71 | }, 72 | "Id": "devHLSEndpoint", 73 | "ManifestName": "index", 74 | "Origination": "ALLOW", 75 | "StartoverWindowSeconds": 0, 76 | "Tags": { 77 | "MSAM-Diagram": "test", 78 | "MSAM-Tile": "test", 79 | "MediaLive-Workflow": "test" 80 | }, 81 | "TimeDelaySeconds": 0, 82 | "Url": "https://abcdef01234567891.mediapackage.eu-west-1.amazonaws.com/out/v1/abcdef01234567891/index.m3u8", 83 | "Whitelist": [] 84 | } 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /infrastructure/lambda/tests/stubs/query_table.json: -------------------------------------------------------------------------------- 1 | { 2 | "Items": [ 3 | { 4 | "ChannelId": "abcdef01234567890", 5 | "Id": "001", 6 | "Name": "Example", 7 | "SK": "OUTPUT#001", 8 | "Url": "https://www.example.com/embed/12345678" 9 | }, 10 | { 11 | "ChannelId": "abcdef01234567890", 12 | "Id": "101", 13 | "Name": "Overlay 1", 14 | "SK": "GRAPHIC#101", 15 | "Url": "https://example.com/output/12345678?aspect=16x9" 16 | }, 17 | { 18 | "AlertedAt": 0.0, 19 | "ChannelId": "abcdef01234567890", 20 | "ExpiresAt": 3600.0, 21 | "Id": "100", 22 | "Message": "foobar", 23 | "SK": "ALERT#100", 24 | "State": "CLEARED" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "browserslist": { 3 | "development": [ 4 | "last 1 chrome version", 5 | "last 1 firefox version", 6 | "last 1 safari version" 7 | ], 8 | "production": [ 9 | ">0.2%", 10 | "not dead", 11 | "not op_mini all" 12 | ] 13 | }, 14 | "dependencies": { 15 | "@aws-amplify/ui-react": "^5.2.0", 16 | "@emotion/react": "^11.10.4", 17 | "@emotion/styled": "^11.10.4", 18 | "@mui/icons-material": "^5.10.6", 19 | "@mui/lab": "^5.0.0-alpha.106", 20 | "@mui/material": "^5.10.8", 21 | "@tanstack/react-query": "^4.10.1", 22 | "@testing-library/jest-dom": "^5.16.5", 23 | "@testing-library/react": "^13.4.0", 24 | "@testing-library/user-event": "^13.5.0", 25 | "aws-amplify": "^5.3.19", 26 | "notistack": "^2.0.5", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "react-error-boundary": "^3.1.4", 30 | "react-hook-form": "^7.39.1", 31 | "react-player": "^2.11.0", 32 | "react-router-dom": "^6.4.3", 33 | "react-use": "^17.4.0", 34 | "web-vitals": "^2.1.4" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ] 41 | }, 42 | "license": "MIT-0", 43 | "name": "aws-medialive-channel-orchestrator", 44 | "private": true, 45 | "scripts": { 46 | "build": "vite build", 47 | "start": "vite", 48 | "serve": "vite preview", 49 | "config": "node cli.js generate-config", 50 | "deploy": "aws s3 cp --recursive dist/ s3://$(npm run-script echo-ui-bucket --silent)", 51 | "echo-ui-bucket": "node cli.js echo-bucket", 52 | "echo-ui-url": "node cli.js echo-url", 53 | "predeploy": "npm run-script config && npm run-script build" 54 | }, 55 | "version": "0.1.0", 56 | "devDependencies": { 57 | "@aws-sdk/client-cloudformation": "^3.398.0", 58 | "@vitejs/plugin-react": "^3.0.1", 59 | "commander": "^9.4.1", 60 | "toml": "^3.0.0", 61 | "vite": "^4.5.3", 62 | "vite-plugin-svgr": "^2.4.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#ffffff", 3 | "display": "standalone", 4 | "name": "AWS MediaLive Channel Orchestrator", 5 | "short_name": "AWS MediaLive Channel Orchestrator", 6 | "start_url": ".", 7 | "theme_color": "#000000" 8 | } 9 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { Amplify, Auth } from "aws-amplify"; 2 | import { Authenticator, ThemeProvider } from "@aws-amplify/ui-react"; 3 | import "@aws-amplify/ui-react/styles.css"; 4 | import { 5 | AWS_REGION, 6 | AWS_USER_POOL_ID, 7 | AWS_USER_POOL_WEB_CLIENT_ID, 8 | API_GATEWAY_ENDPOINT, 9 | } from "./constants"; 10 | import useAmplifyTheme from "./hooks/useAmplifyTheme"; 11 | import { 12 | AppBar, 13 | Box, 14 | Button, 15 | CssBaseline, 16 | IconButton, 17 | Menu, 18 | MenuItem, 19 | Stack, 20 | Toolbar, 21 | Container, 22 | Typography, 23 | } from "@mui/material"; 24 | import { Logout, Menu as MenuIcon } from "@mui/icons-material"; 25 | import { QueryErrorResetBoundary } from "@tanstack/react-query"; 26 | import { ErrorBoundary } from "react-error-boundary"; 27 | import { Outlet, Link, useLocation } from "react-router-dom"; 28 | import { useState } from "react"; 29 | 30 | Amplify.configure({ 31 | Auth: { 32 | // Amazon Cognito Region 33 | region: AWS_REGION, 34 | // Amazon Cognito User Pool ID 35 | userPoolId: AWS_USER_POOL_ID, 36 | // Amazon Cognito Web Client ID (26-char alphanumeric string) 37 | userPoolWebClientId: AWS_USER_POOL_WEB_CLIENT_ID, 38 | mandatorySignIn: true, 39 | }, 40 | API: { 41 | endpoints: [ 42 | { 43 | name: "data", 44 | endpoint: API_GATEWAY_ENDPOINT, 45 | region: AWS_REGION, 46 | custom_header: async () => { 47 | return { 48 | Authorization: `Bearer ${(await Auth.currentSession()) 49 | .getIdToken() 50 | .getJwtToken()}`, 51 | }; 52 | }, 53 | }, 54 | ], 55 | }, 56 | }); 57 | 58 | const components = { 59 | Header() { 60 | return ( 61 | 62 | MediaLive Control Centre 63 | 64 | ); 65 | }, 66 | }; 67 | 68 | const App = ({ signOut }) => { 69 | const location = useLocation(); 70 | const [anchorElNav, setAnchorElNav] = useState(null); 71 | 72 | const handleOpenNavMenu = (event) => { 73 | setAnchorElNav(event.currentTarget); 74 | }; 75 | 76 | const handleCloseNavMenu = () => { 77 | setAnchorElNav(null); 78 | }; 79 | 80 | return ( 81 | <> 82 | 83 | 84 | 85 | 86 | 97 | AWS MediaLive Channel Orchestrator 98 | 99 | 100 | 108 | 109 | 110 | 128 | 134 | 140 | Home 141 | 142 | 143 | 149 | 156 | Config 157 | 158 | 159 | 160 | 161 | 174 | AWS MediaLive Channel Orchestrator 175 | 176 | 177 | 187 | 198 | 199 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | ); 213 | }; 214 | 215 | export default function AuthenticatedApp() { 216 | const theme = useAmplifyTheme(); 217 | return ( 218 | 219 | 220 | {({ signOut, user }) => ( 221 | 222 | {({ reset }) => ( 223 | ( 226 | 227 | 232 | Unexpected Error Occurred! 233 | 234 | {error.message} 235 | 236 | 243 | 244 | 245 | )} 246 | > 247 | 248 | 249 | )} 250 | 251 | )} 252 | 253 | 254 | ); 255 | } 256 | -------------------------------------------------------------------------------- /src/components/AlertsTable.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableContainer, 6 | TableHead, 7 | TableRow, 8 | TablePagination, 9 | } from "@mui/material"; 10 | import { usePagination } from "../hooks/usePagination"; 11 | 12 | const AlertsTable = ({ data = [] }) => { 13 | const { page, rowsPerPage, handleChangePage, handleChangeRowsPerPage } = 14 | usePagination(); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | 22 | State Updated At 23 | State 24 | Message 25 | 26 | 27 | 28 | {data.length === 0 && ( 29 | 30 | No records found 31 | 32 | )} 33 | {data 34 | .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) 35 | .map((i) => { 36 | const date = new Date(0); 37 | date.setSeconds(i.AlertedAt); 38 | const timeString = date.toISOString(); 39 | return ( 40 | 44 | {timeString} 45 | {i.State} 46 | {i.Message} 47 | 48 | ); 49 | })} 50 | 51 |
52 |
53 | 62 | 63 | ); 64 | }; 65 | 66 | export default AlertsTable; 67 | -------------------------------------------------------------------------------- /src/components/ChannelControls.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogActions, 5 | DialogContent, 6 | DialogContentText, 7 | DialogTitle, 8 | Unstable_Grid2 as Grid, 9 | } from "@mui/material"; 10 | import { startableStates, stoppableStates } from "../constants"; 11 | import { useUpdateStatus } from "../hooks/useChannels"; 12 | import { usePrevious } from "react-use"; 13 | import { useState } from "react"; 14 | 15 | export const ChannelControls = ({ channel }) => { 16 | const [action, setAction] = useState(null); 17 | const { updateStatusAsync, isLoading } = useUpdateStatus(channel.Id); 18 | const prevAction = usePrevious(action); 19 | 20 | const confirmAction = () => { 21 | updateStatusAsync({ 22 | status: action, 23 | }) 24 | .catch(console.error) 25 | .finally(resetAction); 26 | }; 27 | 28 | const resetAction = () => setAction(null); 29 | 30 | return ( 31 | <> 32 | 38 | 42 | Confirm {action ?? prevAction} Channel 43 | 44 | 45 | 46 | Please confirm you wish to {action ?? prevAction}{" "} 47 | the channel 48 | 49 | 50 | 51 | 54 | 57 | 58 | 59 | 67 | 68 | 77 | 78 | 79 | 88 | 89 | 90 | 91 | ); 92 | }; 93 | 94 | export default ChannelControls; 95 | -------------------------------------------------------------------------------- /src/components/ChannelSelector.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl, 3 | InputLabel, 4 | MenuItem, 5 | Select, 6 | Typography, 7 | } from "@mui/material"; 8 | 9 | const ChannelSelector = ({ channels, selected, onSelect }) => { 10 | const handleChange = (e) => { 11 | onSelect(channels.find((i) => i.Id === e.target.value)); 12 | }; 13 | 14 | return channels.length > 0 ? ( 15 | 24 | Channel Selector 25 | 38 | 39 | ) : ( 40 | No channels found 41 | ); 42 | }; 43 | 44 | export default ChannelSelector; 45 | -------------------------------------------------------------------------------- /src/components/ChannelStatus.jsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack, Typography, useTheme } from "@mui/material"; 2 | 3 | const stateColourMap = { 4 | IDLE: "error", 5 | RUNNING: "success", 6 | STARTING: "warning", 7 | STOPPING: "warning", 8 | }; 9 | 10 | export const ChannelStatus = ({ state }) => { 11 | const theme = useTheme(); 12 | return ( 13 | 14 | STATE: 15 | 24 | {state} 25 | 26 | ); 27 | }; 28 | 29 | export default ChannelStatus; 30 | -------------------------------------------------------------------------------- /src/components/ConfigTable.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableContainer, 6 | TableHead, 7 | TableRow, 8 | IconButton, 9 | TablePagination, 10 | } from "@mui/material"; 11 | import { Delete } from "@mui/icons-material"; 12 | import { usePagination } from "../hooks/usePagination"; 13 | 14 | const ConfigTable = ({ data = [], onDelete }) => { 15 | const { page, rowsPerPage, handleChangePage, handleChangeRowsPerPage } = 16 | usePagination(); 17 | 18 | return ( 19 | <> 20 | 21 | 22 | 23 | 24 | Name 25 | Url 26 | Actions 27 | 28 | 29 | 30 | {data.length === 0 && ( 31 | 32 | No records found 33 | 34 | )} 35 | {data 36 | .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) 37 | .map((i) => { 38 | return ( 39 | 43 | {i.Name} 44 | {i.Url} 45 | 46 | onDelete(i.Id)} 52 | > 53 | 54 | 55 | 56 | 57 | ); 58 | })} 59 | 60 |
61 |
62 | 71 | 72 | ); 73 | }; 74 | 75 | export default ConfigTable; 76 | -------------------------------------------------------------------------------- /src/components/DiscoveredOutputsTable.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Typography, 4 | CircularProgress, 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableContainer, 9 | TableHead, 10 | TableRow, 11 | TablePagination, 12 | } from "@mui/material"; 13 | import { useDiscoverOutputs } from "../hooks/useDiscoverOutputs"; 14 | import usePagination from "../hooks/usePagination"; 15 | 16 | export const DiscoveredOutputsTable = ({ 17 | existingOutputs = [], 18 | channelId = undefined, 19 | onAdd, 20 | }) => { 21 | const { page, rowsPerPage, handleChangePage, handleChangeRowsPerPage } = 22 | usePagination(); 23 | const { data, isLoading, isError } = useDiscoverOutputs(channelId); 24 | if (isLoading) return ; 25 | if (isError) 26 | return ( 27 | Unable to discover any outputs 28 | ); 29 | return ( 30 | <> 31 | 32 | 33 | 34 | 35 | Type 36 | Name 37 | URL 38 | Actions 39 | 40 | 41 | 42 | {data 43 | .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) 44 | .map((i, idx) => { 45 | return ( 46 | 50 | 51 | {i.Type} 52 | 53 | {i.Name} 54 | {i.Url} 55 | 56 | 63 | 64 | 65 | ); 66 | })} 67 | 68 |
69 |
70 | 79 | 80 | ); 81 | }; 82 | 83 | export default DiscoveredOutputsTable; 84 | -------------------------------------------------------------------------------- /src/components/GraphicForm.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogContentText, 8 | DialogTitle, 9 | FormControl, 10 | FormControlLabel, 11 | Input, 12 | InputAdornment, 13 | InputLabel, 14 | MenuItem, 15 | Select, 16 | Stack, 17 | Switch, 18 | Typography, 19 | } from "@mui/material"; 20 | import { useEffect, useState } from "react"; 21 | import { useInsertGraphic } from "../hooks/useChannels"; 22 | import { RUNNING_STATE } from "../constants"; 23 | 24 | function isNumeric(str) { 25 | if (typeof str !== "string") return false; 26 | return !isNaN(str) && !isNaN(parseInt(str)) && isFinite(parseInt(str)); 27 | } 28 | 29 | const GraphicForm = ({ graphics = [], channel }) => { 30 | const [showDialog, setShowDialog] = useState(false); 31 | const [enableDuration, setEnableDuration] = useState(false); 32 | const [duration, setDuration] = useState(""); 33 | const [selectedGraphic, setSelectedGraphic] = useState(null); 34 | const { insertGraphicAsync } = useInsertGraphic(channel.Id); 35 | 36 | useEffect(() => { 37 | setSelectedGraphic((e) => { 38 | if (!e) return graphics[0]; 39 | return graphics.find((i) => i.Id === e.Id) ? e : graphics[0]; 40 | }); 41 | }, [graphics]); 42 | 43 | const closeDialog = () => { 44 | setShowDialog(false); 45 | setDuration(""); 46 | setEnableDuration(false); 47 | }; 48 | 49 | const handleDurationToggle = (e) => { 50 | setEnableDuration(!e.target.checked); 51 | }; 52 | 53 | const handleDurationChange = (e) => { 54 | setDuration(e.target.value); 55 | }; 56 | 57 | const confirmAction = () => { 58 | const input = { 59 | graphicId: selectedGraphic.Id, 60 | }; 61 | if (enableDuration) input.Duration = parseInt(duration) * 1000; 62 | insertGraphicAsync(input).catch(console.error).finally(closeDialog); 63 | }; 64 | 65 | const handleChange = (e) => { 66 | setSelectedGraphic(graphics.find((i) => i.Id === e.target.value)); 67 | }; 68 | 69 | return graphics.length > 0 ? ( 70 | <> 71 | 77 | 81 | Confirm Insert Graphic 82 | 83 | 84 | 85 | Please confirm how long to display the graphic{" "} 86 | {selectedGraphic?.Name} on the channel 87 | 88 | 89 | 95 | } 96 | label="Show indefinitely" 97 | /> 98 | {enableDuration && ( 99 | 100 | seconds 106 | } 107 | inputProps={{ 108 | "aria-label": "seconds", 109 | }} 110 | /> 111 | 112 | )} 113 | 114 | 115 | 116 | 117 | 124 | 125 | 126 | 127 | 131 | Graphic Selector 132 | 147 | 148 | 149 | 157 | 158 | 159 | 160 | ) : ( 161 | No graphics available 162 | ); 163 | }; 164 | 165 | export default GraphicForm; 166 | -------------------------------------------------------------------------------- /src/components/InputTable.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Chip, 4 | Stack, 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableContainer, 9 | TableHead, 10 | TableRow, 11 | TablePagination, 12 | } from "@mui/material"; 13 | import { usePrepareInput, useUpdateInput } from "../hooks/useChannels"; 14 | import { useState } from "react"; 15 | import { RUNNING_STATE } from "../constants"; 16 | import { usePagination } from "../hooks/usePagination"; 17 | 18 | export const InputTable = ({ channel }) => { 19 | const inputs = channel.InputAttachments ?? []; 20 | const { updateInputAsync, isLoading: isUpdatingInput } = useUpdateInput( 21 | channel.Id 22 | ); 23 | const { prepareInputAsync, isLoading: isPreparingInput } = usePrepareInput( 24 | channel.Id 25 | ); 26 | const [canPrepareInput, setCanPrepareInput] = useState(true); 27 | const [canUpdateInput, setCanUpdateInput] = useState(true); 28 | const { page, rowsPerPage, handleChangePage, handleChangeRowsPerPage } = 29 | usePagination(); 30 | 31 | const debounce = (fn, stateSetter, delay) => (val) => { 32 | stateSetter(false); 33 | fn(val) 34 | .then(() => { 35 | setTimeout(() => { 36 | stateSetter(true); 37 | }, delay); 38 | }) 39 | .catch(() => stateSetter(true)); 40 | }; 41 | 42 | const prepare = debounce(prepareInputAsync, setCanPrepareInput, 5000); 43 | const update = debounce(updateInputAsync, setCanUpdateInput, 5000); 44 | 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | Name 52 | Attached to Pipeline 53 | Actions 54 | 55 | 56 | 57 | {inputs.length === 0 && ( 58 | 59 | No inputs found 60 | 61 | )} 62 | {inputs 63 | .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) 64 | .map((input) => { 65 | const isActive = input.Active; 66 | return ( 67 | 71 | 72 | {input.Name} 73 | 74 | 75 | 79 | 80 | 81 | {!isActive && ( 82 | 87 | 98 | 109 | 110 | )} 111 | 112 | 113 | ); 114 | })} 115 | 116 |
117 |
118 | 127 | 128 | ); 129 | }; 130 | 131 | export default InputTable; 132 | -------------------------------------------------------------------------------- /src/components/OutputSelector.jsx: -------------------------------------------------------------------------------- 1 | import { ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; 2 | 3 | const OutputSelector = ({ outputs = [], selected, onSelect }) => { 4 | const handleChange = (e) => { 5 | onSelect(outputs.find((i) => i.Id === e.target.value)); 6 | }; 7 | 8 | return outputs.length > 0 ? ( 9 | 17 | {outputs.map((i) => ( 18 | 19 | {i.Name} 20 | 21 | ))} 22 | 23 | ) : ( 24 | No outputs found 25 | ); 26 | }; 27 | 28 | export default OutputSelector; 29 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | /************* ENVIRONMENT VARIABLES **************/ 2 | export const AWS_REGION = import.meta.env.VITE_APP_AWS_REGION ?? "eu-west-1"; 3 | export const AWS_COGNITO_IDENTITY_POOL_ID = 4 | import.meta.env.VITE_APP_AWS_COGNITO_IDENTITY_POOL_ID ?? 5 | ""; 6 | export const AWS_USER_POOL_ID = 7 | import.meta.env.VITE_APP_AWS_USER_POOL_ID ?? ""; 8 | export const AWS_USER_POOL_WEB_CLIENT_ID = 9 | import.meta.env.VITE_APP_AWS_USER_POOL_WEB_CLIENT_ID ?? 10 | ""; 11 | export const API_GATEWAY_ENDPOINT = 12 | import.meta.env.VITE_APP_API_GATEWAY_ENDPOINT ?? ""; 13 | /************* END OF ENVIRONMENT VARIABLES **************/ 14 | export const RUNNING_STATE = "RUNNING"; 15 | export const IDLE_STATE = "IDLE"; 16 | export const UPDATE_FAILED = "UPDATE_FAILED"; 17 | export const startableStates = [IDLE_STATE, UPDATE_FAILED]; 18 | export const stoppableStates = [RUNNING_STATE]; 19 | -------------------------------------------------------------------------------- /src/hooks/useAmplifyTheme.js: -------------------------------------------------------------------------------- 1 | import { useTheme as useMuiTheme } from "@mui/material"; 2 | 3 | export const useAmplifyTheme = () => { 4 | const { palette } = useMuiTheme(); 5 | 6 | return { 7 | name: "Auth Theme", 8 | tokens: { 9 | colors: { 10 | border: { 11 | error: { 12 | value: palette.error.main, 13 | }, 14 | }, 15 | brand: { 16 | primary: { 17 | 80: { value: palette.primary.dark }, 18 | 90: { value: palette.primary.main }, 19 | 100: { value: palette.primary.dark }, 20 | }, 21 | }, 22 | }, 23 | components: { 24 | text: { 25 | error: { 26 | color: { value: palette.error.main }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }; 32 | }; 33 | 34 | export default useAmplifyTheme; 35 | -------------------------------------------------------------------------------- /src/hooks/useApi.js: -------------------------------------------------------------------------------- 1 | import { API } from "aws-amplify"; 2 | 3 | export const useApi = (apiName = "data") => { 4 | return { 5 | get: (path, clientConfig = {}) => API.get(apiName, path, clientConfig), 6 | post: (path, data, clientConfig = {}) => 7 | API.post(apiName, path, { 8 | body: data, 9 | ...clientConfig, 10 | }), 11 | put: (path, data, clientConfig = {}) => 12 | API.put(apiName, path, data, { 13 | body: data, 14 | ...clientConfig, 15 | }), 16 | delete: (path, clientConfig = {}) => API.del(apiName, path, clientConfig), 17 | }; 18 | }; 19 | 20 | export default useApi; 21 | -------------------------------------------------------------------------------- /src/hooks/useChannels.js: -------------------------------------------------------------------------------- 1 | import useApi from "./useApi"; 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 3 | import { useSnackbar } from "notistack"; 4 | 5 | export const CHANNELS_PATH = "channels"; 6 | export const GRAPHICS_PATH = "graphics"; 7 | export const OUTPUTS_PATH = "outputs"; 8 | 9 | export const useChannels = (config = {}) => { 10 | const { get } = useApi(); 11 | 12 | return useQuery( 13 | [CHANNELS_PATH], 14 | () => 15 | get(`/${CHANNELS_PATH}`).catch((err) => { 16 | console.error(err); 17 | throw new Error(`Unable to retrieve channels`); 18 | }), 19 | { 20 | refetchInterval: 3000, 21 | useErrorBoundary: true, 22 | ...config, 23 | } 24 | ); 25 | }; 26 | 27 | export const useChannel = (channelId, config = {}) => { 28 | const { get } = useApi(); 29 | 30 | return useQuery( 31 | [CHANNELS_PATH, channelId], 32 | () => 33 | get(`/${CHANNELS_PATH}/${channelId}`).catch((err) => { 34 | console.error(err); 35 | throw new Error(`Unable to retrieve channel`); 36 | }), 37 | { 38 | refetchInterval: 3000, 39 | useErrorBoundary: true, 40 | enabled: !!channelId, 41 | ...config, 42 | } 43 | ); 44 | }; 45 | 46 | export const useUpdateStatus = (channelId, config = {}) => { 47 | const { put } = useApi(); 48 | const queryClient = useQueryClient(); 49 | const { enqueueSnackbar } = useSnackbar(); 50 | const mutation = useMutation( 51 | ({ status }) => put(`/${CHANNELS_PATH}/${channelId}/status/${status}`, {}), 52 | { 53 | onSuccess: (_, data) => { 54 | enqueueSnackbar("Channel update requested", { 55 | variant: "success", 56 | autoHideDuration: 3000, 57 | }); 58 | return queryClient.invalidateQueries([CHANNELS_PATH]); 59 | }, 60 | onError: (err) => { 61 | console.error(err); 62 | enqueueSnackbar("Error updating status", { 63 | variant: "error", 64 | autoHideDuration: 3000, 65 | }); 66 | }, 67 | enabled: !!channelId, 68 | ...config, 69 | } 70 | ); 71 | return { 72 | updateStatus: mutation.mutate, 73 | updateStatusAsync: mutation.mutateAsync, 74 | isLoading: mutation.isLoading, 75 | }; 76 | }; 77 | 78 | export const useUpdateInput = (channelId, config = {}) => { 79 | const { put } = useApi(); 80 | const queryClient = useQueryClient(); 81 | const { enqueueSnackbar } = useSnackbar(); 82 | const mutation = useMutation( 83 | ({ input }) => 84 | put(`/${CHANNELS_PATH}/${channelId}/activeinput/${input}`, {}), 85 | { 86 | onSuccess: (_, data) => { 87 | enqueueSnackbar("Input switch requested", { 88 | variant: "success", 89 | autoHideDuration: 3000, 90 | }); 91 | return queryClient.invalidateQueries([CHANNELS_PATH]); 92 | }, 93 | onError: (err) => { 94 | console.error(err); 95 | enqueueSnackbar("Error switching inputs", { 96 | variant: "error", 97 | autoHideDuration: 3000, 98 | }); 99 | }, 100 | enabled: !!channelId, 101 | ...config, 102 | } 103 | ); 104 | return { 105 | updateInput: mutation.mutate, 106 | updateInputAsync: mutation.mutateAsync, 107 | isLoading: mutation.isLoading, 108 | }; 109 | }; 110 | 111 | export const usePrepareInput = (channelId, config = {}) => { 112 | const { post } = useApi(); 113 | const queryClient = useQueryClient(); 114 | const { enqueueSnackbar } = useSnackbar(); 115 | const mutation = useMutation( 116 | ({ input }) => 117 | post(`/${CHANNELS_PATH}/${channelId}/prepareinput/${input}`, {}), 118 | { 119 | onSuccess: (_, data) => { 120 | enqueueSnackbar("Prepare input requested", { 121 | variant: "success", 122 | autoHideDuration: 3000, 123 | }); 124 | return queryClient.invalidateQueries([CHANNELS_PATH]); 125 | }, 126 | onError: (err) => { 127 | console.error(err); 128 | enqueueSnackbar("Error preparing input", { 129 | variant: "error", 130 | autoHideDuration: 3000, 131 | }); 132 | }, 133 | enabled: !!channelId, 134 | ...config, 135 | } 136 | ); 137 | return { 138 | prepareInput: mutation.mutate, 139 | prepareInputAsync: mutation.mutateAsync, 140 | isLoading: mutation.isLoading, 141 | }; 142 | }; 143 | 144 | export const useInsertGraphic = (channelId, config = {}) => { 145 | const { post } = useApi(); 146 | const queryClient = useQueryClient(); 147 | const { enqueueSnackbar } = useSnackbar(); 148 | const mutation = useMutation( 149 | ({ graphicId, ...rest }) => 150 | post( 151 | `/${CHANNELS_PATH}/${channelId}/${GRAPHICS_PATH}/${graphicId}/start`, 152 | rest 153 | ), 154 | { 155 | onSuccess: (_, data) => { 156 | enqueueSnackbar("Insert graphic requested", { 157 | variant: "success", 158 | autoHideDuration: 3000, 159 | }); 160 | return queryClient.invalidateQueries([CHANNELS_PATH]); 161 | }, 162 | onError: (err) => { 163 | console.error(err); 164 | enqueueSnackbar("Error inserting graphic", { 165 | variant: "error", 166 | autoHideDuration: 3000, 167 | }); 168 | }, 169 | enabled: !!channelId, 170 | ...config, 171 | } 172 | ); 173 | return { 174 | insertGraphic: mutation.mutate, 175 | insertGraphicAsync: mutation.mutateAsync, 176 | isLoading: mutation.isLoading, 177 | }; 178 | }; 179 | 180 | export const useAddChannelData = (channelId, config = {}) => { 181 | const { post } = useApi(); 182 | const queryClient = useQueryClient(); 183 | const { enqueueSnackbar } = useSnackbar(); 184 | const mutation = useMutation( 185 | ({ dataType, data }) => 186 | post(`/${CHANNELS_PATH}/${channelId}/${dataType}`, data), 187 | { 188 | onSuccess: (_, { data, dataType }) => { 189 | enqueueSnackbar(`Added ${data.Name} to ${dataType}`, { 190 | variant: "success", 191 | autoHideDuration: 3000, 192 | }); 193 | return queryClient.invalidateQueries([CHANNELS_PATH, channelId]); 194 | }, 195 | onError: (err, { data, dataType }) => { 196 | console.error(err); 197 | enqueueSnackbar(`Error adding ${data.Name} to ${dataType}`, { 198 | variant: "error", 199 | autoHideDuration: 3000, 200 | }); 201 | }, 202 | enabled: !!channelId, 203 | ...config, 204 | } 205 | ); 206 | return { 207 | addChannelData: mutation.mutate, 208 | addChannelDataAsync: mutation.mutateAsync, 209 | isLoading: mutation.isLoading, 210 | }; 211 | }; 212 | 213 | export const useRemoveChannelData = (channelId, config = {}) => { 214 | const methods = useApi(); 215 | const queryClient = useQueryClient(); 216 | const { enqueueSnackbar } = useSnackbar(); 217 | const mutation = useMutation( 218 | ({ dataType, id }) => 219 | methods.delete(`/${CHANNELS_PATH}/${channelId}/${dataType}/${id}`), 220 | { 221 | onSuccess: (_, { id, dataType }) => { 222 | enqueueSnackbar(`Deleted ${dataType.replace(/s+$/, "")}`, { 223 | variant: "success", 224 | autoHideDuration: 3000, 225 | }); 226 | return queryClient.invalidateQueries([CHANNELS_PATH, channelId]); 227 | }, 228 | onError: (err, { id, dataType }) => { 229 | console.error(err); 230 | enqueueSnackbar(`Error deleting ${dataType.replace(/s+$/, "")}`, { 231 | variant: "error", 232 | autoHideDuration: 3000, 233 | }); 234 | }, 235 | enabled: !!channelId, 236 | ...config, 237 | } 238 | ); 239 | return { 240 | removeChannelData: mutation.mutate, 241 | removeChannelDataAsync: mutation.mutateAsync, 242 | isLoading: mutation.isLoading, 243 | }; 244 | }; 245 | 246 | export const useStopGraphics = (channelId, config = {}) => { 247 | const { post } = useApi(); 248 | const queryClient = useQueryClient(); 249 | const { enqueueSnackbar } = useSnackbar(); 250 | const mutation = useMutation( 251 | () => post(`/${CHANNELS_PATH}/${channelId}/${GRAPHICS_PATH}/stop`), 252 | { 253 | onSuccess: () => { 254 | enqueueSnackbar(`Stop graphics requested`, { 255 | variant: "success", 256 | autoHideDuration: 3000, 257 | }); 258 | return queryClient.invalidateQueries([CHANNELS_PATH, channelId]); 259 | }, 260 | onError: (err) => { 261 | console.error(err); 262 | enqueueSnackbar(`Error stopping graphics`, { 263 | variant: "error", 264 | autoHideDuration: 3000, 265 | }); 266 | }, 267 | enabled: !!channelId, 268 | ...config, 269 | } 270 | ); 271 | return { 272 | stopGraphics: mutation.mutate, 273 | stopGraphicsAsync: mutation.mutateAsync, 274 | isLoading: mutation.isLoading, 275 | }; 276 | }; 277 | 278 | const hooks = { 279 | useChannels, 280 | useUpdateStatus, 281 | usePrepareInput, 282 | useUpdateInput, 283 | useInsertGraphic, 284 | useAddChannelData, 285 | useRemoveChannelData, 286 | useStopGraphics, 287 | }; 288 | 289 | export default hooks; 290 | -------------------------------------------------------------------------------- /src/hooks/useDiscoverOutputs.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { CHANNELS_PATH, OUTPUTS_PATH } from "./useChannels"; 3 | import useApi from "./useApi"; 4 | 5 | export const DISCOVER_PATH = "discover"; 6 | 7 | export const useDiscoverOutputs = (channelId, config = {}) => { 8 | const { get } = useApi(); 9 | 10 | return useQuery( 11 | [CHANNELS_PATH, OUTPUTS_PATH, DISCOVER_PATH, channelId], 12 | () => 13 | get(`/${CHANNELS_PATH}/${channelId}/${OUTPUTS_PATH}/${DISCOVER_PATH}`) 14 | .catch((err) => { 15 | console.error(err); 16 | throw new Error(`Unable to retrieve channels`); 17 | }) 18 | .then((res) => res?.Outputs ?? []), 19 | { 20 | refetchInterval: 0, 21 | useErrorBoundary: false, 22 | enabled: !!channelId, 23 | ...config, 24 | } 25 | ); 26 | }; 27 | 28 | const hooks = { 29 | useDiscoverOutputs, 30 | }; 31 | 32 | export default hooks; 33 | -------------------------------------------------------------------------------- /src/hooks/usePagination.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export const usePagination = () => { 4 | const [page, setPage] = useState(0); 5 | const [rowsPerPage, setRowsPerPage] = useState(5); 6 | 7 | const handleChangePage = (_, newPage) => setPage(newPage); 8 | 9 | const handleChangeRowsPerPage = (event) => { 10 | setRowsPerPage(event.target.value); 11 | setPage(0); 12 | }; 13 | 14 | return { 15 | page, 16 | rowsPerPage, 17 | handleChangePage, 18 | handleChangeRowsPerPage, 19 | }; 20 | }; 21 | 22 | export default usePagination; 23 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import reportWebVitals from "./reportWebVitals"; 5 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 6 | import { SnackbarProvider } from "notistack"; 7 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 8 | import Home from "./routes/Home"; 9 | import Config from "./routes/Config"; 10 | 11 | const queryClient = new QueryClient(); 12 | 13 | const router = createBrowserRouter([ 14 | { 15 | path: "/", 16 | element: , 17 | children: [ 18 | { index: true, element: }, 19 | { path: "config", element: }, 20 | ], 21 | }, 22 | ]); 23 | 24 | const root = ReactDOM.createRoot(document.getElementById("root")); 25 | root.render( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | 35 | // If you want to start measuring performance in your app, pass a function 36 | // to log results (for example: reportWebVitals(console.log)) 37 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 38 | reportWebVitals(); 39 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = (onPerfEntry) => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src/routes/Config.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import "@aws-amplify/ui-react/styles.css"; 3 | import { 4 | useAddChannelData, 5 | useChannel, 6 | useChannels, 7 | useRemoveChannelData, 8 | } from "../hooks/useChannels"; 9 | import { 10 | Box, 11 | CircularProgress, 12 | Tab, 13 | Stack, 14 | Button, 15 | TextField, 16 | Dialog, 17 | DialogTitle, 18 | DialogContent, 19 | Typography, 20 | } from "@mui/material"; 21 | import { TabContext, TabList, TabPanel } from "@mui/lab"; 22 | import ConfigTable from "../components/ConfigTable"; 23 | import ChannelSelector from "../components/ChannelSelector"; 24 | import { useForm, Controller } from "react-hook-form"; 25 | import DiscoveredOutputsTable from "../components/DiscoveredOutputsTable"; 26 | 27 | const OUTPUTS = "outputs"; 28 | const GRAPHICS = "graphics"; 29 | 30 | const ConfigForm = ({ onSubmit, onCancel }) => { 31 | const { control, handleSubmit } = useForm({ 32 | defaultValues: { 33 | Name: "", 34 | Url: "https://", 35 | }, 36 | shouldFocusError: true, 37 | }); 38 | return ( 39 | 40 | ( 48 | 57 | )} 58 | /> 59 | ( 67 | 76 | )} 77 | /> 78 | 79 | 80 | 81 | 82 | 83 | ); 84 | }; 85 | 86 | const Config = () => { 87 | const { data = { Channels: [] }, isLoading: loadingChannels } = useChannels(); 88 | const channels = data.Channels; 89 | const [dataType, setDataType] = useState(OUTPUTS); 90 | const [showForm, setShowForm] = useState(false); 91 | const [selectedChannel, setSelectedChannel] = useState(null); 92 | const { data: channelData = {}, isLoading: loadingSelectedChannel } = 93 | useChannel(selectedChannel?.Id); 94 | const { removeChannelDataAsync } = useRemoveChannelData(selectedChannel?.Id); 95 | const { addChannelDataAsync } = useAddChannelData(selectedChannel?.Id); 96 | 97 | useEffect(() => { 98 | setSelectedChannel((e) => 99 | data.Channels.length > 0 && !e ? data.Channels[0] : e 100 | ); 101 | }, [data]); 102 | 103 | useEffect(() => { 104 | setSelectedChannel((e) => channels.find((i) => i.Id === e.Id)); 105 | }, [channels]); 106 | 107 | const closeDialog = () => { 108 | setShowForm(false); 109 | }; 110 | 111 | const handleSubmitConfigForm = (data) => 112 | addChannelDataAsync({ dataType: dataType, data }) 113 | .catch(() => console.error("Unable to create item")) 114 | .finally(closeDialog); 115 | 116 | const onAddDiscovered = ({ Name, Url }) => { 117 | handleSubmitConfigForm({ 118 | Name, 119 | Url, 120 | }); 121 | }; 122 | 123 | return ( 124 | 125 | 126 | {loadingChannels ? ( 127 | 128 | ) : ( 129 | 134 | )} 135 | 136 | {selectedChannel && ( 137 | <> 138 | 144 | 148 | Add {dataType} 149 | 150 | 151 | 155 | 156 | 157 | {loadingSelectedChannel ? ( 158 | 159 | ) : ( 160 | <> 161 | 162 | 163 | setDataType(val)} 165 | aria-label="config tabs" 166 | > 167 | 168 | 169 | 170 | 171 | 172 | 175 | removeChannelDataAsync({ id, dataType: OUTPUTS }) 176 | } 177 | /> 178 | 179 | 180 | {!channelData?.GraphicsEnabled && ( 181 | 182 | 183 | Motion graphics are not enabled for this channel 184 | 185 | 186 | )} 187 | 190 | removeChannelDataAsync({ id, dataType: GRAPHICS }) 191 | } 192 | /> 193 | 194 | 195 | 196 | 197 | 198 | 199 | )} 200 | 201 | )} 202 | {(dataType === OUTPUTS && selectedChannel) && ( 203 | <> 204 | Discovered Outputs 205 | 210 | 211 | )} 212 | 213 | ); 214 | }; 215 | 216 | export default Config; 217 | -------------------------------------------------------------------------------- /src/routes/Home.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import "@aws-amplify/ui-react/styles.css"; 3 | import { useChannels, useChannel, useStopGraphics } from "../hooks/useChannels"; 4 | import { 5 | Box, 6 | CircularProgress, 7 | Stack, 8 | Typography, 9 | Unstable_Grid2 as Grid, 10 | Button, 11 | } from "@mui/material"; 12 | import ReactPlayer from "react-player"; 13 | import ChannelSelector from "../components/ChannelSelector"; 14 | import OutputSelector from "../components/OutputSelector"; 15 | import InputTable from "../components/InputTable"; 16 | import ChannelStatus from "../components/ChannelStatus"; 17 | import ChannelControls from "../components/ChannelControls"; 18 | import GraphicForm from "../components/GraphicForm"; 19 | import { RUNNING_STATE } from "../constants"; 20 | import AlertsTable from "../components/AlertsTable"; 21 | 22 | const StatusNote = ({ type }) => ( 23 | 24 | {type} controls are unavailable whilst a channel is not running 25 | 26 | ); 27 | 28 | const Home = () => { 29 | const { data = { Channels: [] }, isLoading: loadingChannels } = useChannels(); 30 | const channels = data.Channels; 31 | const [selectedChannel, setSelectedChannel] = useState(null); 32 | const [selectedOutput, setSelectedOutput] = useState(null); 33 | const { data: channelData = {}, isLoading: loadingSelectedChannel } = 34 | useChannel(selectedChannel?.Id); 35 | const outputs = channelData?.Outputs ?? []; 36 | const graphics = channelData?.Graphics ?? []; 37 | const { stopGraphicsAsync, isLoading: stoppingGraphics } = useStopGraphics( 38 | selectedChannel?.Id 39 | ); 40 | 41 | useEffect(() => { 42 | setSelectedChannel((e) => 43 | data.Channels.length > 0 && !e ? data.Channels[0] : e 44 | ); 45 | }, [data]); 46 | 47 | useEffect(() => { 48 | if (selectedChannel && channelData) { 49 | setSelectedOutput((e) => { 50 | if (channelData.Outputs?.map((i) => i.Url).includes(e?.Url)) return e; 51 | return channelData.Outputs?.length ? channelData.Outputs[0] : null; 52 | }); 53 | } 54 | }, [data, channelData, selectedChannel]); 55 | 56 | useEffect(() => { 57 | setSelectedChannel((e) => channels.find((i) => i.Id === e.Id)); 58 | }, [channels]); 59 | 60 | return ( 61 | <> 62 | 63 | 64 | 65 | 66 | {loadingChannels ? ( 67 | 68 | ) : ( 69 | 74 | )} 75 | 76 | {!loadingChannels && selectedChannel && ( 77 | <> 78 | {outputs.length > 0 ? ( 79 | <> 80 | 88 | 104 | 105 | 110 | 111 | ) : ( 112 | No outputs available to display 113 | )} 114 | 115 | )} 116 | 117 | 118 | 119 | {selectedChannel && ( 120 | <> 121 | {!loadingSelectedChannel ? ( 122 | 123 | Channel Controls 124 | 125 | 126 | 127 | 128 | 129 | 130 | Inputs 131 | {selectedChannel.State !== RUNNING_STATE && ( 132 | 133 | )} 134 | 135 | 136 | 137 | 138 | Graphics 139 | {selectedChannel.State !== RUNNING_STATE && ( 140 | 141 | )} 142 | 143 | {!channelData.GraphicsEnabled ? ( 144 | 145 | Motion graphics not enabled for this channel 146 | 147 | ) : ( 148 | <> 149 | 153 | 154 | 165 | 166 | 167 | )} 168 | 169 | 170 | Alerts 171 | 172 | 173 | 174 | ) : ( 175 | 176 | )} 177 | 178 | )} 179 | 180 | 181 | 182 | ); 183 | }; 184 | 185 | export default Home; 186 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | Transform: AWS::Serverless-2016-10-31 2 | Description: Infrastructure for AWS MediaLive Channel Orchestrator 3 | 4 | Globals: 5 | Function: 6 | Runtime: python3.9 7 | Timeout: 5 8 | Environment: 9 | Variables: 10 | CHANNEL_TABLE: !Ref ChannelTable 11 | ALLOW_ORIGIN: !If 12 | - DefaultAccessControlOrigin 13 | - !Sub 'https://${CloudFrontDistribution.DomainName}' 14 | - !Ref AccessControlAllowOriginOverride 15 | 16 | Parameters: 17 | Stage: 18 | Type: String 19 | Default: dev 20 | Description: API Stage 21 | AccessLogsBucket: 22 | Type: String 23 | Default: "" 24 | Description: Optional bucket for access logs. Leave blank to disable access logging 25 | AccessControlAllowOriginOverride: 26 | Type: String 27 | Default: "" 28 | Description: Optional override for the CORS policy. Leave blank to scope CORS to the CloudFront distribution 29 | DefaultUserEmail: 30 | Type: String 31 | Default: "" 32 | Description: Optional email for the default admin user. Leave blank to skip creation 33 | CognitoAdvancedSecurity: 34 | Description: The type of Cognito advanced security to enable. Disabled by default. 35 | Type: String 36 | Default: "OFF" 37 | AllowedValues: 38 | - "OFF" 39 | - "AUDIT" 40 | - "ENFORCED" 41 | EnableBackups: 42 | Description: Whether to enable DynamoDB backups. Disabled by default. 43 | Type: String 44 | Default: "false" 45 | AllowedValues: 46 | - "true" 47 | - "false" 48 | AlertExpiry: 49 | Type: Number 50 | Default: "12" 51 | Description: The number of hours to retain cleared alert messages. Specify 0 to retain indefinitely 52 | 53 | Conditions: 54 | WithAccessLogs: !Not [!Equals [!Ref AccessLogsBucket, ""]] 55 | DefaultAccessControlOrigin: 56 | !Equals [!Ref AccessControlAllowOriginOverride, ""] 57 | CreateUser: !Not [!Equals [!Ref DefaultUserEmail, ""]] 58 | 59 | Resources: 60 | WebUIBucket: 61 | Type: AWS::S3::Bucket 62 | Properties: 63 | VersioningConfiguration: 64 | Status: Enabled 65 | BucketEncryption: 66 | ServerSideEncryptionConfiguration: 67 | - ServerSideEncryptionByDefault: 68 | SSEAlgorithm: AES256 69 | LoggingConfiguration: !If 70 | - WithAccessLogs 71 | - DestinationBucketName: !Ref AccessLogsBucket 72 | LogFilePrefix: !Ref AWS::StackName 73 | - !Ref AWS::NoValue 74 | 75 | WebUIBucketPolicy: 76 | Type: AWS::S3::BucketPolicy 77 | Properties: 78 | Bucket: !Ref WebUIBucket 79 | PolicyDocument: 80 | Statement: 81 | - Sid: HttpsOnly 82 | Action: "*" 83 | Effect: Deny 84 | Resource: 85 | - !Sub arn:${AWS::Partition}:s3:::${WebUIBucket} 86 | - !Sub arn:${AWS::Partition}:s3:::${WebUIBucket}/* 87 | Principal: "*" 88 | Condition: 89 | Bool: 90 | "aws:SecureTransport": "false" 91 | - Sid: CloudFrontOriginOnly 92 | Action: s3:GetObject 93 | Effect: Allow 94 | Resource: !Sub arn:${AWS::Partition}:s3:::${WebUIBucket}/* 95 | Principal: 96 | Service: "cloudfront.amazonaws.com" 97 | Condition: 98 | ArnEquals: 99 | aws:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution} 100 | 101 | CloudFrontOriginAccessControl: 102 | Type: AWS::CloudFront::OriginAccessControl 103 | Properties: 104 | OriginAccessControlConfig: 105 | Name: !Sub '${AWS::StackName}-S3AccessControl' 106 | OriginAccessControlOriginType: s3 107 | SigningBehavior: always 108 | SigningProtocol: sigv4 109 | 110 | CloudFrontDistribution: 111 | Type: AWS::CloudFront::Distribution 112 | Properties: 113 | DistributionConfig: 114 | Origins: 115 | - DomainName: !GetAtt WebUIBucket.RegionalDomainName 116 | Id: S3Origin 117 | OriginAccessControlId: !Ref CloudFrontOriginAccessControl 118 | S3OriginConfig: {} 119 | Enabled: true 120 | HttpVersion: http2 121 | Comment: The UI distribution 122 | DefaultRootObject: index.html 123 | DefaultCacheBehavior: 124 | AllowedMethods: 125 | - HEAD 126 | - GET 127 | - OPTIONS 128 | TargetOriginId: S3Origin 129 | ForwardedValues: 130 | QueryString: false 131 | Cookies: 132 | Forward: none 133 | ViewerProtocolPolicy: redirect-to-https 134 | PriceClass: PriceClass_All 135 | ViewerCertificate: 136 | CloudFrontDefaultCertificate: true 137 | CustomErrorResponses: 138 | - ErrorCode: 404 139 | ResponseCode: 200 140 | ResponsePagePath: /index.html 141 | - ErrorCode: 403 142 | ResponseCode: 200 143 | ResponsePagePath: /index.html 144 | Logging: !If 145 | - WithAccessLogs 146 | - Bucket: !Sub ${AccessLogsBucket}.s3.${AWS::URLSuffix} 147 | IncludeCookies: false 148 | Prefix: !Sub ${AWS::StackName}/ 149 | - !Ref AWS::NoValue 150 | 151 | UserPool: 152 | Type: AWS::Cognito::UserPool 153 | Properties: 154 | AdminCreateUserConfig: 155 | AllowAdminCreateUserOnly: true 156 | InviteMessageTemplate: 157 | EmailMessage: "Your AWS MediaLive Channel Orchestrator username is {username} and the temporary password is {####}" 158 | EmailSubject: "Your temporary password for AWS MediaLive Channel Orchestrator" 159 | AutoVerifiedAttributes: 160 | - email 161 | UserPoolAddOns: 162 | AdvancedSecurityMode: !Ref CognitoAdvancedSecurity 163 | Policies: 164 | PasswordPolicy: 165 | MinimumLength: 8 166 | RequireLowercase: true 167 | RequireNumbers: true 168 | RequireSymbols: true 169 | RequireUppercase: true 170 | 171 | UserPoolClient: 172 | Type: AWS::Cognito::UserPoolClient 173 | Properties: 174 | UserPoolId: !Ref UserPool 175 | SupportedIdentityProviders: 176 | - COGNITO 177 | 178 | CognitoUserPoolAdmin: 179 | Condition: CreateUser 180 | Type: AWS::Cognito::UserPoolUser 181 | Properties: 182 | Username: admin 183 | DesiredDeliveryMediums: 184 | - EMAIL 185 | UserPoolId: !Ref UserPool 186 | UserAttributes: 187 | - Name: email 188 | Value: !Ref DefaultUserEmail 189 | - Name: email_verified 190 | Value: "True" 191 | 192 | ApiGatewayApi: 193 | Type: AWS::Serverless::Api 194 | Properties: 195 | StageName: !Ref Stage 196 | Cors: 197 | AllowMethods: "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'" 198 | AllowHeaders: "'Content-Type,X-Amz-Date,X-Amz-Security-Token,Authorization,X-Api-Key,X-Requested-With,Accept,Access-Control-Allow-Methods,Access-Control-Allow-Origin,Access-Control-Allow-Headers'" 199 | AllowOrigin: !If 200 | - DefaultAccessControlOrigin 201 | - !Sub "'https://${CloudFrontDistribution.DomainName}'" 202 | - !Sub "'${AccessControlAllowOriginOverride}'" 203 | GatewayResponses: 204 | DEFAULT_4XX: 205 | ResponseTemplates: 206 | "application/json": '{ "Message": $context.error.messageString }' 207 | ResponseParameters: 208 | Headers: 209 | Access-Control-Allow-Methods: "'*'" 210 | Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" 211 | Access-Control-Allow-Origin: !If 212 | - DefaultAccessControlOrigin 213 | - !Sub "'https://${CloudFrontDistribution.DomainName}'" 214 | - !Sub "'${AccessControlAllowOriginOverride}'" 215 | DEFAULT_5XX: 216 | ResponseTemplates: 217 | "application/json": '{ "Message": $context.error.messageString }' 218 | ResponseParameters: 219 | Headers: 220 | Access-Control-Allow-Methods: "'*'" 221 | Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" 222 | Access-Control-Allow-Origin: !If 223 | - DefaultAccessControlOrigin 224 | - !Sub "'https://${CloudFrontDistribution.DomainName}'" 225 | - !Sub "'${AccessControlAllowOriginOverride}'" 226 | Auth: 227 | AddDefaultAuthorizerToCorsPreflight: false 228 | DefaultAuthorizer: Cognito 229 | Authorizers: 230 | Cognito: 231 | UserPoolArn: 232 | Fn::GetAtt: [UserPool, Arn] 233 | 234 | ApiHandler: 235 | Type: AWS::Serverless::Function 236 | Properties: 237 | CodeUri: infrastructure/lambda/api 238 | Handler: app.lambda_handler 239 | Architectures: 240 | - arm64 241 | Layers: 242 | - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:37 243 | Policies: 244 | - DynamoDBCrudPolicy: 245 | TableName: !Ref ChannelTable 246 | - Statement: 247 | - Sid: MediaLivePackage 248 | Effect: Allow 249 | Action: 250 | - medialive:List* 251 | - medialive:Describe* 252 | - medialive:StopChannel 253 | - medialive:StartChannel 254 | - medialive:BatchUpdateSchedule 255 | - mediapackage:List* 256 | Resource: "*" 257 | Events: 258 | AnyApi: 259 | Type: Api 260 | Properties: 261 | Path: /{proxy+} 262 | Method: ANY 263 | RestApiId: !Ref ApiGatewayApi 264 | 265 | ChannelTable: 266 | Type: AWS::DynamoDB::Table 267 | DeletionPolicy: Delete 268 | UpdateReplacePolicy: Delete 269 | Properties: 270 | AttributeDefinitions: 271 | - AttributeName: "ChannelId" 272 | AttributeType: "S" 273 | - AttributeName: "SK" 274 | AttributeType: "S" 275 | BillingMode: PAY_PER_REQUEST 276 | TimeToLiveSpecification: 277 | AttributeName: ExpiresAt 278 | Enabled: true 279 | PointInTimeRecoverySpecification: 280 | PointInTimeRecoveryEnabled: !Ref EnableBackups 281 | SSESpecification: 282 | SSEEnabled: true 283 | SSEType: KMS 284 | KeySchema: 285 | - AttributeName: "ChannelId" 286 | KeyType: "HASH" 287 | - AttributeName: "SK" 288 | KeyType: "RANGE" 289 | 290 | MediaLiveEventHandler: 291 | Type: AWS::Serverless::Function 292 | Properties: 293 | CodeUri: infrastructure/lambda/events 294 | Handler: index.lambda_handler 295 | Architectures: 296 | - arm64 297 | Layers: 298 | - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:37 299 | Environment: 300 | Variables: 301 | ALERT_EXPIRY: !Ref AlertExpiry 302 | Policies: 303 | - DynamoDBCrudPolicy: 304 | TableName: !Ref ChannelTable 305 | Events: 306 | MediaLiveEvent: 307 | Type: EventBridgeRule 308 | Properties: 309 | Pattern: 310 | source: 311 | - "aws.medialive" 312 | 313 | 314 | Outputs: 315 | CognitoUserPoolID: 316 | Description: The UserPool ID 317 | Value: !Ref UserPool 318 | CognitoWebClientID: 319 | Description: The web client ID 320 | Value: !Ref UserPoolClient 321 | ApiEndpoint: 322 | Description: API Gateway endpoint URL 323 | Value: !Sub "https://${ApiGatewayApi}.execute-api.${AWS::Region}.amazonaws.com/${Stage}" 324 | Region: 325 | Description: Deployment region 326 | Value: !Ref AWS::Region 327 | WebUIBucket: 328 | Description: Web UI S3 Bucket 329 | Value: !Ref WebUIBucket 330 | WebUrl: 331 | Description: The web frontend URL 332 | Value: !Sub "https://${CloudFrontDistribution.DomainName}" 333 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | define: { 8 | 'window.global': {} 9 | }, 10 | resolve: { 11 | alias: { 12 | './runtimeConfig': './runtimeConfig.browser', 13 | }, 14 | extensions: [".jsx", ".js"], 15 | }, 16 | }); 17 | --------------------------------------------------------------------------------