├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── docker-push.yml │ ├── docker-tests.yml │ ├── goreleaser-test.yml │ ├── goreleaser.yml │ └── main.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── config ├── config.go └── config_test.go ├── db ├── dbtest │ ├── fake_db.go │ └── fake_db_test.go ├── redis │ ├── job.go │ ├── job_test.go │ ├── localpreset.go │ ├── localpreset_test.go │ ├── presetmap.go │ ├── presetmap_test.go │ ├── redis.go │ ├── redis_test.go │ └── storage │ │ ├── redis.go │ │ ├── redis_test.go │ │ ├── stub_test.go │ │ └── testdata │ │ └── sentinel.conf ├── repo.go ├── types.go └── types_test.go ├── doc.go ├── go.mod ├── go.sum ├── internal └── provider │ ├── bitmovin │ ├── bitmovin.go │ └── bitmovin_test.go │ ├── description.go │ ├── elementalconductor │ ├── client.go │ ├── elementalconductor.go │ ├── elementalconductor_fake_transcode_test.go │ ├── elementalconductor_test.go │ └── fake_server_test.go │ ├── encodingcom │ ├── encodingcom.go │ ├── encodingcom_server_test.go │ └── encodingcom_test.go │ ├── fake_provider_test.go │ ├── hybrik │ └── hybrik.go │ ├── mediaconvert │ ├── factory_test.go │ ├── fake_client_test.go │ ├── mediaconvert.go │ ├── mediaconvert_test.go │ └── preset_mapping.go │ ├── provider.go │ ├── provider_test.go │ └── zencoder │ ├── zencoder.go │ ├── zencoder_fake_test.go │ └── zencoder_test.go ├── logo ├── logo.png └── logo.svg ├── main.go ├── service ├── fake_provider_test.go ├── preset.go ├── preset_params.go ├── preset_responses.go ├── preset_test.go ├── presetmap.go ├── presetmap_params.go ├── presetmap_test.go ├── provider.go ├── provider_params.go ├── provider_responses.go ├── provider_test.go ├── response.go ├── service.go ├── swagger.go ├── swagger_test.go ├── testdata │ └── swagger.json ├── transcode.go ├── transcode_params.go ├── transcode_responses.go └── transcode_test.go └── swagger ├── handler.go ├── handler_test.go ├── response.go └── response_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 99 8 | - package-ecosystem: docker 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 99 13 | - package-ecosystem: github-actions 14 | directory: "/" 15 | schedule: 16 | interval: daily 17 | open-pull-requests-limit: 99 18 | -------------------------------------------------------------------------------- /.github/workflows/docker-push.yml: -------------------------------------------------------------------------------- 1 | name: docker-push 2 | on: create 3 | 4 | jobs: 5 | build-and-push: 6 | if: github.event.ref_type == 'tag' 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3.0.0 10 | 11 | - name: docker meta 12 | id: meta 13 | uses: docker/metadata-action@v3.6.2 14 | with: 15 | images: videodev/video-transcoding-api 16 | tags: | 17 | type=ref,event=branch 18 | type=ref,event=pr 19 | type=semver,pattern={{version}} 20 | type=semver,pattern={{major}}.{{minor}} 21 | type=semver,pattern={{major}} 22 | 23 | - name: setup qemu 24 | uses: docker/setup-qemu-action@v1.2.0 25 | 26 | - name: setup buildx 27 | id: buildx 28 | uses: docker/setup-buildx-action@v1.6.0 29 | 30 | - name: login to docker hub 31 | uses: docker/login-action@v1.14.1 32 | with: 33 | username: ${{ secrets.DOCKER_USERNAME }} 34 | password: ${{ secrets.DOCKER_PASSWORD }} 35 | 36 | - name: build and push 37 | uses: docker/build-push-action@v2.9.0 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | platforms: linux/amd64,linux/arm64 44 | -------------------------------------------------------------------------------- /.github/workflows/docker-tests.yml: -------------------------------------------------------------------------------- 1 | name: docker-tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | test-dockerfile: 11 | name: test-dockerfile 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3.0.0 15 | 16 | - name: setup qemu 17 | uses: docker/setup-qemu-action@v1.2.0 18 | 19 | - name: setup buildx 20 | id: buildx 21 | uses: docker/setup-buildx-action@v1.6.0 22 | 23 | - name: build 24 | uses: docker/build-push-action@v2.9.0 25 | with: 26 | context: . 27 | push: false 28 | tags: videodev/video-transcoding-api:latest 29 | platforms: linux/amd64,linux/arm64 30 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser-test.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser-test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - .goreleaser.yml 8 | pull_request: 9 | paths: 10 | - .goreleaser.yml 11 | jobs: 12 | test-goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3.0.0 16 | 17 | - uses: docker://goreleaser/goreleaser 18 | with: 19 | args: release --snapshot -f .goreleaser.yml 20 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | on: create 3 | 4 | jobs: 5 | release: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3.0.0 9 | if: github.event.ref_type == 'tag' 10 | with: 11 | fetch-depth: 0 12 | 13 | - name: fetch tags 14 | if: github.event.ref_type == 'tag' 15 | run: git fetch --tags --prune --prune-tags --force 16 | 17 | - uses: docker://goreleaser/goreleaser 18 | if: github.event.ref_type == 'tag' 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | entrypoint: bash 23 | args: -c "goreleaser release -f .goreleaser.yml" 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | matrix: 15 | go_version: 16 | - 1.16 17 | - 1.17 18 | os: 19 | - macos 20 | - ubuntu 21 | arch: 22 | - 386 23 | - amd64 24 | exclude: 25 | - os: macos 26 | arch: 386 27 | 28 | name: tests (${{ matrix.os }}/go-${{ matrix.go_version }}-${{ matrix.arch }}) 29 | runs-on: ${{ matrix.os }}-latest 30 | steps: 31 | - uses: actions/setup-go@v3.0.0 32 | id: go 33 | with: 34 | stable: false 35 | go-version: ${{ matrix.go_version }} 36 | 37 | - uses: actions/checkout@v3.0.0 38 | 39 | - name: install build deps (macos) 40 | if: ${{ matrix.os == 'macos' }} 41 | run: | 42 | brew update 43 | brew install coreutils redis 44 | 45 | - name: install build deps (ubuntu) 46 | if: ${{ matrix.os == 'ubuntu' }} 47 | run: | 48 | sudo apt update -y 49 | sudo apt install -y redis-server 50 | 51 | # only need to do this on macos because on Ubuntu Redis will run as a service. 52 | - name: start redis 53 | if: ${{ matrix.os == 'macos' }} 54 | run: | 55 | brew services start redis 56 | timeout 10 sh -c "while ! redis-cli ping; do echo waiting for redis-server to start; sleep 1; done" 57 | 58 | - name: run-tests-race 59 | if: ${{ matrix.arch == 'amd64' }} 60 | env: 61 | GOARCH: "${{ matrix.arch }}" 62 | REDIS_HOST: redis 63 | run: go test -race -vet all -mod readonly ./... 64 | 65 | - name: run-tests 66 | if: ${{ matrix.arch == '386' }} 67 | env: 68 | GOARCH: "${{ matrix.arch }}" 69 | REDIS_HOST: redis 70 | run: go test -vet all -mod readonly ./... 71 | 72 | lint: 73 | name: lint 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/checkout@v3.0.0 77 | 78 | - uses: golangci/golangci-lint-action@v3.1.0 79 | 80 | build-artifact: 81 | name: build 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v3.0.0 85 | 86 | - uses: actions/setup-go@v3.0.0 87 | id: go 88 | with: 89 | go-version: 1.17 90 | 91 | - name: go-build 92 | run: go build -o video-transcoding-api 93 | env: 94 | CGO_ENABLED: 0 95 | 96 | unblock-pr: 97 | name: unblock-pr 98 | runs-on: ubuntu-latest 99 | needs: 100 | - build-artifact 101 | - lint 102 | - test 103 | steps: 104 | - run: "true" 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | access.log 2 | video-transcoding-api 3 | secrets.yml 4 | coverage.txt 5 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 5m 3 | 4 | linters: 5 | disable-all: true 6 | enable: 7 | - gofmt 8 | - goimports 9 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | goarch: 5 | - amd64 6 | - arm64 7 | goos: 8 | - darwin 9 | - linux 10 | archives: 11 | - replacements: 12 | darwin: Darwin 13 | linux: Linux 14 | files: 15 | - LICENSE 16 | - README.md 17 | checksum: 18 | name_template: "checksums.txt" 19 | snapshot: 20 | name_template: "{{ .Tag }}-next" 21 | changelog: 22 | sort: asc 23 | filters: 24 | exclude: 25 | - "^Merge" 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to video-transcoding-api 2 | 3 | video-transcoding-api is an open source project originally started by a handful 4 | of developers at The New York Times and open to the entire open source video 5 | community. 6 | 7 | We really appreciate your help! 8 | 9 | ## Filing issues 10 | 11 | When filing an issue, make sure to answer these five questions: 12 | 13 | 1. What version of Go are you using (``go version``)? 14 | 2. What operating system and processor architecture are you using? 15 | 3. What did you do? 16 | 4. What did you expect to see? 17 | 5. What did you see instead? 18 | 19 | Feel free to open issues asking general questions along with feature requests. 20 | 21 | ## Contributing code 22 | 23 | Pull requests are very welcome! Before submitting changes, please follow these 24 | guidelines: 25 | 26 | 1. Check the open issues and pull requests for existing discussions. 27 | 2. Open an issue to discuss a new feature. 28 | 3. Write tests. 29 | 4. Make sure code follows the ['Go Code Review Comments'](https://github.com/golang/go/wiki/CodeReviewComments). 30 | 5. Make sure your changes pass the test suite (invoke ``make test`` to run it). 31 | 6. Make sure the entire test suite passes locally and on Travis CI. 32 | 7. Open a Pull Request. 33 | 34 | ## License 35 | 36 | Unless otherwise noted, the video-transcoding-api source files are distributed 37 | under the Apache 2.0-style license found in the LICENSE file. 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18rc1-alpine AS build 2 | 3 | ENV CGO_ENABLED 0 4 | WORKDIR /code 5 | ADD . ./ 6 | RUN go install 7 | 8 | FROM alpine:3.15.0 9 | RUN apk add --no-cache ca-certificates 10 | COPY --from=build /go/bin/video-transcoding-api /usr/bin/video-transcoding-api 11 | ENTRYPOINT ["/usr/bin/video-transcoding-api"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016-2019 The New York Times Company 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all testdeps lint runlint test gotest coverage gocoverage build run 2 | 3 | HTTP_PORT ?= 8080 4 | LOG_LEVEL ?= debug 5 | 6 | all: test 7 | 8 | testdeps: 9 | cd /tmp && GO111MODULE=on go get github.com/golangci/golangci-lint/cmd/golangci-lint@latest 10 | go mod download 11 | 12 | lint: testdeps runlint 13 | 14 | runlint: 15 | golangci-lint run 16 | 17 | gotest: 18 | go test -race -vet=all -mod=readonly $(GO_TEST_EXTRA_FLAGS) ./... 19 | 20 | test: lint testdeps gotest 21 | 22 | coverage: lint gocoverage 23 | 24 | gocoverage: 25 | make gotest GO_TEST_EXTRA_FLAGS="-coverprofile=coverage.txt -covermode=atomic" 26 | 27 | build: 28 | go build -mod=readonly -o video-transcoding-api 29 | 30 | run: build 31 | HTTP_PORT=$(HTTP_PORT) APP_LOG_LEVEL=$(LOG_LEVEL) ./video-transcoding-api 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![video-transcoding-api logo](https://cloud.githubusercontent.com/assets/244265/14191217/ae825932-f764-11e5-8eb3-d070aa8f2676.png) 2 | 3 | # Video Transcoding API 4 | 5 | [![Build Status](https://github.com/video-dev/video-transcoding-api/actions/workflows/main.yml/badge.svg)](https://github.com/video-dev/video-transcoding-api/actions/workflows/main.yml) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/video-dev/video-transcoding-api)](https://goreportcard.com/report/github.com/video-dev/video-transcoding-api) 7 | [![Docker Pulls](https://img.shields.io/docker/pulls/videodev/video-transcoding-api?color=green)](https://hub.docker.com/r/videodev/video-transcoding-api) 8 | 9 | The Video Transcoding API provides an agnostic API to transcode media assets 10 | across different cloud services. Currently, it supports the following 11 | providers: 12 | 13 | - [Bitmovin](http://bitmovin.com) 14 | - [Elemental Conductor](http://www.elementaltechnologies.com/products/elemental-conductor) 15 | - [Encoding.com](http://encoding.com) 16 | - [Hybrik](https://www.hybrik.com) 17 | - [Zencoder](http://zencoder.com) 18 | - [MediaConvert](https://aws.amazon.com/mediaconvert) 19 | 20 | ## Setting Up 21 | 22 | With [latest Go](https://golang.org/dl/) installed, make sure to export the follow 23 | environment variables: 24 | 25 | ### Providers configuration 26 | 27 | #### For [Bitmovin](http://bitmovin.com) 28 | 29 | ``` 30 | export BITMOVIN_API_KEY=your.api.key 31 | export BITMOVIN_AWS_ACCESS_KEY_ID=your.access.key.id 32 | export BITMOVIN_AWS_SECRET_ACCESS_KEY=your.secret.access.key 33 | export BITMOVIN_AWS_STORAGE_REGION=your.s3.region.such.as.US_EAST_1.or.EU_WEST_1 34 | export BITMOVIN_DESTINATION=s3://your-s3-bucket 35 | export BITMOVIN_ENCODING_REGION=your.provider.region.such.as.AWS_US_EAST_1.or.GOOGLE_EUROPE_WEST_1 36 | export BITMOVIN_ENCODING_VERSION=STABLE.or.BETA 37 | ``` 38 | 39 | #### For [Elemental Conductor](http://www.elementaltechnologies.com/products/elemental-conductor) 40 | 41 | ``` 42 | export ELEMENTALCONDUCTOR_HOST=https://conductor-address.cloud.elementaltechnologies.com/ 43 | export ELEMENTALCONDUCTOR_USER_LOGIN=your.login 44 | export ELEMENTALCONDUCTOR_API_KEY=your.api.key 45 | export ELEMENTALCONDUCTOR_AUTH_EXPIRES=30 46 | export ELEMENTALCONDUCTOR_AWS_ACCESS_KEY_ID=your.access.key.id 47 | export ELEMENTALCONDUCTOR_AWS_SECRET_ACCESS_KEY=your.secret.access.key 48 | export ELEMENTALCONDUCTOR_DESTINATION=s3://your-s3-bucket/ 49 | ``` 50 | 51 | #### For [Encoding.com](http://encoding.com) 52 | 53 | ``` 54 | export ENCODINGCOM_USER_ID=your.user.id 55 | export ENCODINGCOM_USER_KEY=your.user.key 56 | export ENCODINGCOM_DESTINATION=http://access.key.id:secret.access.key@your-s3-bucket.s3.amazonaws.com/ 57 | export ENCODINGCOM_REGION="us-east-1" 58 | ``` 59 | 60 | #### For [Hybrik](https://www.hybrik.com) 61 | 62 | ``` 63 | export HYBRIK_URL=your.hybrik.api.endpoint.such.as.https://api_demo.hybrik.com/v1 64 | export HYBRIK_COMPLIANCE_DATE=20170601 65 | export HYBRIK_OAPI_KEY=your.hybrik.oapi.key 66 | export HYBRIK_OAPI_SECRET=your.hybrik.oapi.secret 67 | export HYBRIK_AUTH_KEY=your.hybrik.auth.key 68 | export HYBRIK_AUTH_SECRET=your.hybrik.auth.secret 69 | export HYBRIK_DESTINATION=s3://your-s3-bucket 70 | export HYBRIK_PRESET_PATH=video-transcoding-api-presets 71 | ``` 72 | 73 | ``HYBRIK_PRESET_PATH`` is optional and defines the folder presets will be 74 | stored in. If not specified, it will default to 75 | 'video-transcoding-api-presets'. 76 | 77 | #### For [Zencoder](http://zencoder.com) 78 | 79 | ``` 80 | export ZENCODER_API_KEY=your.api.key 81 | export ZENCODER_DESTINATION=http://access.key.id:secret.access.key@your-s3-bucket.s3.amazonaws.com/ 82 | ``` 83 | 84 | #### For [MediaConvert](https://aws.amazon.com/mediaconvert/) 85 | 86 | ``` 87 | export MEDIACONVERT_AWS_ACCESS_KEY_ID=your.access.key.id 88 | export MEDIACONVERT_AWS_SECRET_ACCESS_KEY=your.secret.access.key 89 | export MEDIACONVERT_AWS_REGION="us-east-1" 90 | export MEDIACONVERT_ENDPOINT=your.mediaconvert.endpoint 91 | export MEDIACONVERT_QUEUE_ARN=your.queue.arn 92 | export MEDIACONVERT_ROLE_ARN=your.iam.role.arn 93 | export MEDIACONVERT_DESTINATION=s3://your-s3-bucket 94 | ``` 95 | 96 | ### Database configuration 97 | 98 | In order to store preset maps and job statuses we need a Redis instance 99 | running. Learn how to setup and run a Redis 100 | [here](http://redis.io/topics/quickstart). With the Redis instance running, set 101 | its configuration variables: 102 | 103 | ``` 104 | export REDIS_ADDR=192.0.2.31 105 | export REDIS_PASSWORD=p4ssw0rd.here 106 | ``` 107 | 108 | If you are running Redis in the same host of the API and on the default port 109 | (6379) the API will automatically find the instance and connect to it. 110 | 111 | With all environment variables set and redis up and running, clone this 112 | repository and run: 113 | 114 | ``` 115 | $ git clone https://github.com/video-dev/video-transcoding-api.git 116 | $ make run 117 | ``` 118 | 119 | ## Running tests 120 | 121 | ``` 122 | $ make test 123 | ``` 124 | 125 | ## Using the API 126 | 127 | Check out on our Wiki [how 128 | to](https://github.com/video-dev/video-transcoding-api/wiki/Using-Video-Transcoding-API) 129 | use this API. 130 | 131 | ## Contributing 132 | 133 | 1. Fork it 134 | 2. Create your feature branch: `git checkout -b my-awesome-new-feature` 135 | 3. Commit your changes: `git commit -m 'Add some awesome feature'` 136 | 4. Push to the branch: `git push origin my-awesome-new-feature` 137 | 5. Submit a pull request 138 | 139 | ## License 140 | 141 | - This code is under [Apache 2.0 142 | license](https://github.com/video-dev/video-transcoding-api/blob/master/LICENSE). 143 | - The video-transcoding-api logo is a variation on the Go gopher that was 144 | designed by Renee French and copyrighted under the [Creative Commons 145 | Attribution 3.0 license](https://creativecommons.org/licenses/by/3.0/). 146 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/NYTimes/gizmo/server" 5 | logging "github.com/fsouza/gizmo-stackdriver-logging" 6 | "github.com/kelseyhightower/envconfig" 7 | "github.com/video-dev/video-transcoding-api/v2/db/redis/storage" 8 | ) 9 | 10 | // Config is a struct to contain all the needed configuration for the 11 | // Transcoding API. 12 | type Config struct { 13 | Server *server.Config 14 | SwaggerManifest string `envconfig:"SWAGGER_MANIFEST_PATH"` 15 | DefaultSegmentDuration uint `envconfig:"DEFAULT_SEGMENT_DURATION" default:"5"` 16 | Redis *storage.Config 17 | EncodingCom *EncodingCom 18 | ElementalConductor *ElementalConductor 19 | Hybrik *Hybrik 20 | Zencoder *Zencoder 21 | Bitmovin *Bitmovin 22 | MediaConvert *MediaConvert 23 | Log *logging.Config 24 | } 25 | 26 | // EncodingCom represents the set of configurations for the Encoding.com 27 | // provider. 28 | type EncodingCom struct { 29 | UserID string `envconfig:"ENCODINGCOM_USER_ID"` 30 | UserKey string `envconfig:"ENCODINGCOM_USER_KEY"` 31 | Destination string `envconfig:"ENCODINGCOM_DESTINATION"` 32 | Region string `envconfig:"ENCODINGCOM_REGION"` 33 | StatusEndpoint string `envconfig:"ENCODINGCOM_STATUS_ENDPOINT" default:"http://status.encoding.com"` 34 | } 35 | 36 | // Zencoder represents the set of configurations for the Zencoder 37 | // provider. 38 | type Zencoder struct { 39 | APIKey string `envconfig:"ZENCODER_API_KEY"` 40 | Destination string `envconfig:"ZENCODER_DESTINATION"` 41 | } 42 | 43 | // ElementalConductor represents the set of configurations for the Elemental 44 | // Conductor provider. 45 | type ElementalConductor struct { 46 | Host string `envconfig:"ELEMENTALCONDUCTOR_HOST"` 47 | UserLogin string `envconfig:"ELEMENTALCONDUCTOR_USER_LOGIN"` 48 | APIKey string `envconfig:"ELEMENTALCONDUCTOR_API_KEY"` 49 | AuthExpires int `envconfig:"ELEMENTALCONDUCTOR_AUTH_EXPIRES"` 50 | AccessKeyID string `envconfig:"ELEMENTALCONDUCTOR_AWS_ACCESS_KEY_ID"` 51 | SecretAccessKey string `envconfig:"ELEMENTALCONDUCTOR_AWS_SECRET_ACCESS_KEY"` 52 | Destination string `envconfig:"ELEMENTALCONDUCTOR_DESTINATION"` 53 | } 54 | 55 | // Bitmovin represents the set of configurations for the Bitmovin 56 | // provider. 57 | type Bitmovin struct { 58 | APIKey string `envconfig:"BITMOVIN_API_KEY"` 59 | Endpoint string `envconfig:"BITMOVIN_ENDPOINT" default:"https://api.bitmovin.com/v1/"` 60 | Timeout uint `envconfig:"BITMOVIN_TIMEOUT" default:"5"` 61 | AccessKeyID string `envconfig:"BITMOVIN_AWS_ACCESS_KEY_ID"` 62 | SecretAccessKey string `envconfig:"BITMOVIN_AWS_SECRET_ACCESS_KEY"` 63 | Destination string `envconfig:"BITMOVIN_DESTINATION"` 64 | AWSStorageRegion string `envconfig:"BITMOVIN_AWS_STORAGE_REGION" default:"US_EAST_1"` 65 | EncodingRegion string `envconfig:"BITMOVIN_ENCODING_REGION" default:"AWS_US_EAST_1"` 66 | EncodingVersion string `envconfig:"BITMOVIN_ENCODING_VERSION" default:"STABLE"` 67 | } 68 | 69 | // Hybrik represents the set of configurations for the Hybrik 70 | // provider. 71 | type Hybrik struct { 72 | URL string `envconfig:"HYBRIK_URL"` 73 | ComplianceDate string `envconfig:"HYBRIK_COMPLIANCE_DATE" default:"20170601"` 74 | OAPIKey string `envconfig:"HYBRIK_OAPI_KEY"` 75 | OAPISecret string `envconfig:"HYBRIK_OAPI_SECRET"` 76 | AuthKey string `envconfig:"HYBRIK_AUTH_KEY"` 77 | AuthSecret string `envconfig:"HYBRIK_AUTH_SECRET"` 78 | Destination string `envconfig:"HYBRIK_DESTINATION"` 79 | PresetPath string `envconfig:"HYBRIK_PRESET_PATH" default:"transcoding-api-presets"` 80 | } 81 | 82 | // MediaConvert represents the set of configurations for the MediaConvert 83 | // provider. 84 | type MediaConvert struct { 85 | AccessKeyID string `envconfig:"MEDIACONVERT_AWS_ACCESS_KEY_ID"` 86 | SecretAccessKey string `envconfig:"MEDIACONVERT_AWS_SECRET_ACCESS_KEY"` 87 | Region string `envconfig:"MEDIACONVERT_AWS_REGION"` 88 | Endpoint string `envconfig:"MEDIACONVERT_ENDPOINT"` 89 | Queue string `envconfig:"MEDIACONVERT_QUEUE_ARN"` 90 | Role string `envconfig:"MEDIACONVERT_ROLE_ARN"` 91 | Destination string `envconfig:"MEDIACONVERT_DESTINATION"` 92 | } 93 | 94 | // LoadConfig loads the configuration of the API using environment variables. 95 | func LoadConfig() *Config { 96 | var cfg Config 97 | envconfig.Process("", &cfg) 98 | return &cfg 99 | } 100 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/NYTimes/gizmo/server" 8 | logging "github.com/fsouza/gizmo-stackdriver-logging" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-cmp/cmp/cmpopts" 11 | "github.com/video-dev/video-transcoding-api/v2/db/redis/storage" 12 | ) 13 | 14 | func TestLoadConfigFromEnv(t *testing.T) { 15 | os.Clearenv() 16 | accessLog := "/var/log/transcoding-api-access.log" 17 | setEnvs(map[string]string{ 18 | "SENTINEL_ADDRS": "10.10.10.10:26379,10.10.10.11:26379,10.10.10.12:26379", 19 | "SENTINEL_MASTER_NAME": "super-master", 20 | "REDIS_ADDR": "localhost:6379", 21 | "REDIS_PASSWORD": "super-secret", 22 | "REDIS_POOL_SIZE": "100", 23 | "REDIS_POOL_TIMEOUT_SECONDS": "10", 24 | "ENCODINGCOM_USER_ID": "myuser", 25 | "ENCODINGCOM_USER_KEY": "secret-key", 26 | "ENCODINGCOM_DESTINATION": "https://safe-stuff", 27 | "ENCODINGCOM_STATUS_ENDPOINT": "https://safe-status", 28 | "ENCODINGCOM_REGION": "sa-east-1", 29 | "AWS_ACCESS_KEY_ID": "AKIANOTREALLY", 30 | "AWS_SECRET_ACCESS_KEY": "secret-key", 31 | "AWS_REGION": "us-east-1", 32 | "ELEMENTALCONDUCTOR_HOST": "elemental-server", 33 | "ELEMENTALCONDUCTOR_USER_LOGIN": "myuser", 34 | "ELEMENTALCONDUCTOR_API_KEY": "secret-key", 35 | "ELEMENTALCONDUCTOR_AUTH_EXPIRES": "30", 36 | "ELEMENTALCONDUCTOR_AWS_ACCESS_KEY_ID": "AKIANOTREALLY", 37 | "ELEMENTALCONDUCTOR_AWS_SECRET_ACCESS_KEY": "secret-key", 38 | "ELEMENTALCONDUCTOR_DESTINATION": "https://safe-stuff", 39 | "BITMOVIN_API_KEY": "secret-key", 40 | "BITMOVIN_ENDPOINT": "bitmovin", 41 | "BITMOVIN_TIMEOUT": "3", 42 | "BITMOVIN_AWS_ACCESS_KEY_ID": "AKIANOTREALLY", 43 | "BITMOVIN_AWS_SECRET_ACCESS_KEY": "secret-key", 44 | "BITMOVIN_DESTINATION": "https://safe-stuff", 45 | "BITMOVIN_AWS_STORAGE_REGION": "US_WEST_1", 46 | "BITMOVIN_ENCODING_REGION": "GOOGLE_EUROPE_WEST_1", 47 | "BITMOVIN_ENCODING_VERSION": "notstable", 48 | "MEDIACONVERT_AWS_ACCESS_KEY_ID": "mc-aws-access-key-id", 49 | "MEDIACONVERT_AWS_SECRET_ACCESS_KEY": "mc-aws-secret-access-key", 50 | "MEDIACONVERT_AWS_REGION": "mc-aws-region", 51 | "MEDIACONVERT_ENDPOINT": "http://mc-endpoint.tld", 52 | "MEDIACONVERT_QUEUE_ARN": "arn:aws:mediaconvert:us-east-1:some-queue:queues/Default", 53 | "MEDIACONVERT_ROLE_ARN": "arn:aws:iam::some-account:role/some-role", 54 | "MEDIACONVERT_DESTINATION": "s3://mc-destination/", 55 | "SWAGGER_MANIFEST_PATH": "/opt/video-transcoding-api-swagger.json", 56 | "HTTP_ACCESS_LOG": accessLog, 57 | "HTTP_PORT": "8080", 58 | "DEFAULT_SEGMENT_DURATION": "3", 59 | "LOGGING_LEVEL": "debug", 60 | }) 61 | cfg := LoadConfig() 62 | expectedCfg := Config{ 63 | SwaggerManifest: "/opt/video-transcoding-api-swagger.json", 64 | DefaultSegmentDuration: 3, 65 | Redis: &storage.Config{ 66 | SentinelAddrs: "10.10.10.10:26379,10.10.10.11:26379,10.10.10.12:26379", 67 | SentinelMasterName: "super-master", 68 | RedisAddr: "localhost:6379", 69 | Password: "super-secret", 70 | PoolSize: 100, 71 | PoolTimeout: 10, 72 | }, 73 | EncodingCom: &EncodingCom{ 74 | UserID: "myuser", 75 | UserKey: "secret-key", 76 | Destination: "https://safe-stuff", 77 | StatusEndpoint: "https://safe-status", 78 | Region: "sa-east-1", 79 | }, 80 | Hybrik: &Hybrik{ 81 | ComplianceDate: "20170601", 82 | PresetPath: "transcoding-api-presets", 83 | }, 84 | Zencoder: &Zencoder{}, 85 | ElementalConductor: &ElementalConductor{ 86 | Host: "elemental-server", 87 | UserLogin: "myuser", 88 | APIKey: "secret-key", 89 | AuthExpires: 30, 90 | AccessKeyID: "AKIANOTREALLY", 91 | SecretAccessKey: "secret-key", 92 | Destination: "https://safe-stuff", 93 | }, 94 | Bitmovin: &Bitmovin{ 95 | APIKey: "secret-key", 96 | Endpoint: "bitmovin", 97 | Timeout: 3, 98 | AccessKeyID: "AKIANOTREALLY", 99 | SecretAccessKey: "secret-key", 100 | AWSStorageRegion: "US_WEST_1", 101 | Destination: "https://safe-stuff", 102 | EncodingRegion: "GOOGLE_EUROPE_WEST_1", 103 | EncodingVersion: "notstable", 104 | }, 105 | MediaConvert: &MediaConvert{ 106 | AccessKeyID: "mc-aws-access-key-id", 107 | SecretAccessKey: "mc-aws-secret-access-key", 108 | Region: "mc-aws-region", 109 | Endpoint: "http://mc-endpoint.tld", 110 | Queue: "arn:aws:mediaconvert:us-east-1:some-queue:queues/Default", 111 | Role: "arn:aws:iam::some-account:role/some-role", 112 | Destination: "s3://mc-destination/", 113 | }, 114 | Server: &server.Config{ 115 | HTTPPort: 8080, 116 | HTTPAccessLog: &accessLog, 117 | }, 118 | Log: &logging.Config{ 119 | StackDriverErrorLogName: "error_log", 120 | 121 | Level: "debug", 122 | }, 123 | } 124 | diff := cmp.Diff(*cfg, expectedCfg, cmpopts.IgnoreUnexported(server.Config{})) 125 | if diff != "" { 126 | t.Errorf("LoadConfig(): wrong config\nWant %#v\nGot %#v\nDiff: %v", expectedCfg, *cfg, diff) 127 | } 128 | } 129 | 130 | func TestLoadConfigFromEnvWithDefaults(t *testing.T) { 131 | os.Clearenv() 132 | accessLog := "/var/log/transcoding-api-access.log" 133 | setEnvs(map[string]string{ 134 | "SENTINEL_ADDRS": "10.10.10.10:26379,10.10.10.11:26379,10.10.10.12:26379", 135 | "SENTINEL_MASTER_NAME": "super-master", 136 | "REDIS_PASSWORD": "super-secret", 137 | "REDIS_POOL_SIZE": "100", 138 | "REDIS_POOL_TIMEOUT_SECONDS": "10", 139 | "REDIS_IDLE_TIMEOUT_SECONDS": "30", 140 | "REDIS_IDLE_CHECK_FREQUENCY_SECONDS": "20", 141 | "ENCODINGCOM_USER_ID": "myuser", 142 | "ENCODINGCOM_USER_KEY": "secret-key", 143 | "ENCODINGCOM_DESTINATION": "https://safe-stuff", 144 | "AWS_ACCESS_KEY_ID": "AKIANOTREALLY", 145 | "AWS_SECRET_ACCESS_KEY": "secret-key", 146 | "AWS_REGION": "us-east-1", 147 | "ELEMENTALCONDUCTOR_HOST": "elemental-server", 148 | "ELEMENTALCONDUCTOR_USER_LOGIN": "myuser", 149 | "ELEMENTALCONDUCTOR_API_KEY": "secret-key", 150 | "ELEMENTALCONDUCTOR_AUTH_EXPIRES": "30", 151 | "ELEMENTALCONDUCTOR_AWS_ACCESS_KEY_ID": "AKIANOTREALLY", 152 | "ELEMENTALCONDUCTOR_AWS_SECRET_ACCESS_KEY": "secret-key", 153 | "ELEMENTALCONDUCTOR_DESTINATION": "https://safe-stuff", 154 | "BITMOVIN_API_KEY": "secret-key", 155 | "BITMOVIN_AWS_ACCESS_KEY_ID": "AKIANOTREALLY", 156 | "BITMOVIN_AWS_SECRET_ACCESS_KEY": "secret-key", 157 | "BITMOVIN_DESTINATION": "https://safe-stuff", 158 | "SWAGGER_MANIFEST_PATH": "/opt/video-transcoding-api-swagger.json", 159 | "HTTP_ACCESS_LOG": accessLog, 160 | "HTTP_PORT": "8080", 161 | }) 162 | cfg := LoadConfig() 163 | expectedCfg := Config{ 164 | SwaggerManifest: "/opt/video-transcoding-api-swagger.json", 165 | DefaultSegmentDuration: 5, 166 | Redis: &storage.Config{ 167 | SentinelAddrs: "10.10.10.10:26379,10.10.10.11:26379,10.10.10.12:26379", 168 | SentinelMasterName: "super-master", 169 | RedisAddr: "127.0.0.1:6379", 170 | Password: "super-secret", 171 | PoolSize: 100, 172 | PoolTimeout: 10, 173 | IdleCheckFrequency: 20, 174 | IdleTimeout: 30, 175 | }, 176 | EncodingCom: &EncodingCom{ 177 | UserID: "myuser", 178 | UserKey: "secret-key", 179 | Destination: "https://safe-stuff", 180 | StatusEndpoint: "http://status.encoding.com", 181 | }, 182 | ElementalConductor: &ElementalConductor{ 183 | Host: "elemental-server", 184 | UserLogin: "myuser", 185 | APIKey: "secret-key", 186 | AuthExpires: 30, 187 | AccessKeyID: "AKIANOTREALLY", 188 | SecretAccessKey: "secret-key", 189 | Destination: "https://safe-stuff", 190 | }, 191 | Hybrik: &Hybrik{ 192 | ComplianceDate: "20170601", 193 | PresetPath: "transcoding-api-presets", 194 | }, 195 | Zencoder: &Zencoder{}, 196 | Bitmovin: &Bitmovin{ 197 | APIKey: "secret-key", 198 | Endpoint: "https://api.bitmovin.com/v1/", 199 | Timeout: 5, 200 | AccessKeyID: "AKIANOTREALLY", 201 | SecretAccessKey: "secret-key", 202 | Destination: "https://safe-stuff", 203 | AWSStorageRegion: "US_EAST_1", 204 | EncodingRegion: "AWS_US_EAST_1", 205 | EncodingVersion: "STABLE", 206 | }, 207 | MediaConvert: &MediaConvert{}, 208 | Server: &server.Config{ 209 | HTTPPort: 8080, 210 | HTTPAccessLog: &accessLog, 211 | }, 212 | Log: &logging.Config{ 213 | Level: "info", 214 | 215 | StackDriverErrorLogName: "error_log", 216 | }, 217 | } 218 | diff := cmp.Diff(*cfg, expectedCfg, cmpopts.IgnoreUnexported(server.Config{})) 219 | if diff != "" { 220 | t.Errorf("LoadConfig(): wrong config\nWant %#v\nGot %#v\nDiff: %v", expectedCfg, *cfg, diff) 221 | } 222 | } 223 | 224 | func setEnvs(envs map[string]string) { 225 | for k, v := range envs { 226 | os.Setenv(k, v) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /db/dbtest/fake_db.go: -------------------------------------------------------------------------------- 1 | package dbtest 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/video-dev/video-transcoding-api/v2/db" 8 | ) 9 | 10 | type fakeRepository struct { 11 | triggerError bool 12 | presetmaps map[string]*db.PresetMap 13 | localpresets map[string]*db.LocalPreset 14 | jobs []*db.Job 15 | } 16 | 17 | // NewFakeRepository creates a new instance of the fake repository 18 | // implementation. The underlying fake repository keeps jobs and presets in 19 | // memory. 20 | func NewFakeRepository(triggerError bool) db.Repository { 21 | return &fakeRepository{ 22 | triggerError: triggerError, 23 | presetmaps: make(map[string]*db.PresetMap), 24 | localpresets: make(map[string]*db.LocalPreset), 25 | } 26 | } 27 | 28 | func (d *fakeRepository) CreateJob(job *db.Job) error { 29 | if d.triggerError { 30 | return errors.New("database error") 31 | } 32 | if job.CreationTime.IsZero() { 33 | job.CreationTime = time.Now().UTC() 34 | } 35 | d.jobs = append(d.jobs, job) 36 | return nil 37 | } 38 | 39 | func (d *fakeRepository) DeleteJob(job *db.Job) error { 40 | if d.triggerError { 41 | return errors.New("database error") 42 | } 43 | index, err := d.findJob(job.ID) 44 | if err != nil { 45 | return err 46 | } 47 | for i := index; i < len(d.jobs)-1; i++ { 48 | d.jobs[i] = d.jobs[i+1] 49 | } 50 | d.jobs = d.jobs[:len(d.jobs)-1] 51 | return nil 52 | } 53 | 54 | func (d *fakeRepository) GetJob(id string) (*db.Job, error) { 55 | if d.triggerError { 56 | return nil, errors.New("database error") 57 | } 58 | index, err := d.findJob(id) 59 | if err != nil { 60 | return nil, err 61 | } 62 | return d.jobs[index], nil 63 | } 64 | 65 | func (d *fakeRepository) findJob(id string) (int, error) { 66 | index := -1 67 | for i, job := range d.jobs { 68 | if job.ID == id { 69 | index = i 70 | break 71 | } 72 | } 73 | if index == -1 { 74 | return index, db.ErrJobNotFound 75 | } 76 | return index, nil 77 | } 78 | 79 | func (d *fakeRepository) ListJobs(filter db.JobFilter) ([]db.Job, error) { 80 | if d.triggerError { 81 | return nil, errors.New("database error") 82 | } 83 | jobs := make([]db.Job, 0, len(d.jobs)) 84 | var count uint 85 | for _, job := range d.jobs { 86 | if job.CreationTime.Before(filter.Since) { 87 | continue 88 | } 89 | if filter.Limit != 0 && count == filter.Limit { 90 | break 91 | } 92 | jobs = append(jobs, *job) 93 | count++ 94 | } 95 | return jobs, nil 96 | } 97 | 98 | func (d *fakeRepository) CreatePresetMap(presetmap *db.PresetMap) error { 99 | if d.triggerError { 100 | return errors.New("database error") 101 | } 102 | if presetmap.Name == "" { 103 | return errors.New("invalid presetmap name") 104 | } 105 | if _, ok := d.presetmaps[presetmap.Name]; ok { 106 | return db.ErrPresetMapAlreadyExists 107 | } 108 | d.presetmaps[presetmap.Name] = presetmap 109 | return nil 110 | } 111 | 112 | func (d *fakeRepository) UpdatePresetMap(presetmap *db.PresetMap) error { 113 | if d.triggerError { 114 | return errors.New("database error") 115 | } 116 | if _, ok := d.presetmaps[presetmap.Name]; !ok { 117 | return db.ErrPresetMapNotFound 118 | } 119 | d.presetmaps[presetmap.Name] = presetmap 120 | return nil 121 | } 122 | 123 | func (d *fakeRepository) GetPresetMap(name string) (*db.PresetMap, error) { 124 | if d.triggerError { 125 | return nil, errors.New("database error") 126 | } 127 | if presetmap, ok := d.presetmaps[name]; ok { 128 | return presetmap, nil 129 | } 130 | return nil, db.ErrPresetMapNotFound 131 | } 132 | 133 | func (d *fakeRepository) DeletePresetMap(presetmap *db.PresetMap) error { 134 | if d.triggerError { 135 | return errors.New("database error") 136 | } 137 | if _, ok := d.presetmaps[presetmap.Name]; !ok { 138 | return db.ErrPresetMapNotFound 139 | } 140 | delete(d.presetmaps, presetmap.Name) 141 | return nil 142 | } 143 | 144 | func (d *fakeRepository) ListPresetMaps() ([]db.PresetMap, error) { 145 | if d.triggerError { 146 | return nil, errors.New("database error") 147 | } 148 | presetmaps := make([]db.PresetMap, 0, len(d.presetmaps)) 149 | for _, presetmap := range d.presetmaps { 150 | presetmaps = append(presetmaps, *presetmap) 151 | } 152 | return presetmaps, nil 153 | } 154 | 155 | func (d *fakeRepository) CreateLocalPreset(preset *db.LocalPreset) error { 156 | if d.triggerError { 157 | return errors.New("database error") 158 | } 159 | if preset.Name == "" { 160 | return errors.New("preset name missing") 161 | } 162 | if _, ok := d.localpresets[preset.Name]; ok { 163 | return db.ErrLocalPresetAlreadyExists 164 | } 165 | d.localpresets[preset.Name] = preset 166 | return nil 167 | } 168 | 169 | func (d *fakeRepository) UpdateLocalPreset(preset *db.LocalPreset) error { 170 | if d.triggerError { 171 | return errors.New("database error") 172 | } 173 | if _, ok := d.localpresets[preset.Name]; !ok { 174 | return db.ErrLocalPresetNotFound 175 | } 176 | d.localpresets[preset.Name] = preset 177 | 178 | return nil 179 | } 180 | 181 | func (d *fakeRepository) GetLocalPreset(name string) (*db.LocalPreset, error) { 182 | if d.triggerError { 183 | return nil, errors.New("database error") 184 | } 185 | if localpreset, ok := d.localpresets[name]; ok { 186 | return localpreset, nil 187 | } 188 | return nil, db.ErrLocalPresetNotFound 189 | } 190 | 191 | func (d *fakeRepository) DeleteLocalPreset(preset *db.LocalPreset) error { 192 | if d.triggerError { 193 | return errors.New("database error") 194 | } 195 | if _, ok := d.localpresets[preset.Name]; !ok { 196 | return db.ErrLocalPresetNotFound 197 | } 198 | delete(d.localpresets, preset.Name) 199 | return nil 200 | } 201 | -------------------------------------------------------------------------------- /db/redis/job.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/go-redis/redis" 9 | "github.com/video-dev/video-transcoding-api/v2/db" 10 | "github.com/video-dev/video-transcoding-api/v2/db/redis/storage" 11 | ) 12 | 13 | const jobsSetKey = "jobs" 14 | 15 | func (r *redisRepository) CreateJob(job *db.Job) error { 16 | if job.ID == "" { 17 | return errors.New("job id is required") 18 | } 19 | job.CreationTime = time.Now().UTC().Truncate(time.Millisecond) 20 | return r.saveJob(job) 21 | } 22 | 23 | func (r *redisRepository) saveJob(job *db.Job) error { 24 | fields, err := r.storage.FieldMap(job) 25 | if err != nil { 26 | return err 27 | } 28 | jobKey := r.jobKey(job.ID) 29 | return r.storage.RedisClient().Watch(func(tx *redis.Tx) error { 30 | err := tx.HMSet(jobKey, fields).Err() 31 | if err != nil { 32 | return err 33 | } 34 | return tx.ZAddNX(jobsSetKey, redis.Z{Member: job.ID, Score: float64(job.CreationTime.UnixNano())}).Err() 35 | }, jobKey) 36 | } 37 | 38 | func (r *redisRepository) DeleteJob(job *db.Job) error { 39 | err := r.storage.Delete(r.jobKey(job.ID)) 40 | if err != nil { 41 | if err == storage.ErrNotFound { 42 | return db.ErrJobNotFound 43 | } 44 | return err 45 | } 46 | return r.storage.RedisClient().ZRem(jobsSetKey, job.ID).Err() 47 | } 48 | 49 | func (r *redisRepository) GetJob(id string) (*db.Job, error) { 50 | job := db.Job{ID: id} 51 | err := r.storage.Load(r.jobKey(id), &job) 52 | if err == storage.ErrNotFound { 53 | return nil, db.ErrJobNotFound 54 | } 55 | return &job, err 56 | } 57 | 58 | func (r *redisRepository) ListJobs(filter db.JobFilter) ([]db.Job, error) { 59 | now := time.Now().UTC() 60 | rangeOpts := redis.ZRangeBy{ 61 | Min: strconv.FormatInt(filter.Since.UnixNano(), 10), 62 | Max: strconv.FormatInt(now.UnixNano(), 10), 63 | Count: int64(filter.Limit), 64 | } 65 | if rangeOpts.Count == 0 { 66 | rangeOpts.Count = -1 67 | } 68 | jobIDs, err := r.storage.RedisClient().ZRangeByScore(jobsSetKey, rangeOpts).Result() 69 | if err != nil { 70 | return nil, err 71 | } 72 | jobs := make([]db.Job, 0, len(jobIDs)) 73 | for _, id := range jobIDs { 74 | job, err := r.GetJob(id) 75 | if err != nil && err != db.ErrJobNotFound { 76 | return nil, err 77 | } 78 | if job != nil { 79 | jobs = append(jobs, *job) 80 | } 81 | } 82 | return jobs, nil 83 | } 84 | 85 | func (r *redisRepository) jobKey(id string) string { 86 | return "job:" + id 87 | } 88 | -------------------------------------------------------------------------------- /db/redis/localpreset.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/go-redis/redis" 7 | "github.com/video-dev/video-transcoding-api/v2/db" 8 | "github.com/video-dev/video-transcoding-api/v2/db/redis/storage" 9 | ) 10 | 11 | const localPresetsSetKey = "localpresets" 12 | 13 | func (r *redisRepository) CreateLocalPreset(localPreset *db.LocalPreset) error { 14 | if _, err := r.GetLocalPreset(localPreset.Name); err == nil { 15 | return db.ErrLocalPresetAlreadyExists 16 | } 17 | return r.saveLocalPreset(localPreset) 18 | } 19 | 20 | func (r *redisRepository) UpdateLocalPreset(localPreset *db.LocalPreset) error { 21 | if _, err := r.GetLocalPreset(localPreset.Name); err == db.ErrLocalPresetNotFound { 22 | return err 23 | } 24 | return r.saveLocalPreset(localPreset) 25 | } 26 | 27 | func (r *redisRepository) saveLocalPreset(localPreset *db.LocalPreset) error { 28 | fields, err := r.storage.FieldMap(localPreset) 29 | if err != nil { 30 | return err 31 | } 32 | if localPreset.Name == "" { 33 | return errors.New("preset name missing") 34 | } 35 | localPresetKey := r.localPresetKey(localPreset.Name) 36 | return r.storage.RedisClient().Watch(func(tx *redis.Tx) error { 37 | err := tx.HMSet(localPresetKey, fields).Err() 38 | if err != nil { 39 | return err 40 | } 41 | return tx.SAdd(localPresetsSetKey, localPreset.Name).Err() 42 | }, localPresetKey) 43 | } 44 | 45 | func (r *redisRepository) DeleteLocalPreset(localPreset *db.LocalPreset) error { 46 | err := r.storage.Delete(r.localPresetKey(localPreset.Name)) 47 | if err != nil { 48 | if err == storage.ErrNotFound { 49 | return db.ErrLocalPresetNotFound 50 | } 51 | return err 52 | } 53 | r.storage.RedisClient().SRem(localPresetsSetKey, localPreset.Name) 54 | return nil 55 | } 56 | 57 | func (r *redisRepository) GetLocalPreset(name string) (*db.LocalPreset, error) { 58 | localPreset := db.LocalPreset{Name: name, Preset: db.Preset{}} 59 | err := r.storage.Load(r.localPresetKey(name), &localPreset) 60 | if err == storage.ErrNotFound { 61 | return nil, db.ErrLocalPresetNotFound 62 | } 63 | return &localPreset, err 64 | } 65 | 66 | func (r *redisRepository) localPresetKey(name string) string { 67 | return "localpreset:" + name 68 | } 69 | -------------------------------------------------------------------------------- /db/redis/localpreset_test.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/video-dev/video-transcoding-api/v2/config" 8 | "github.com/video-dev/video-transcoding-api/v2/db" 9 | "github.com/video-dev/video-transcoding-api/v2/db/redis/storage" 10 | ) 11 | 12 | func TestCreateLocalPreset(t *testing.T) { 13 | err := cleanRedis() 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | var cfg config.Config 18 | cfg.Redis = new(storage.Config) 19 | repo, err := NewRepository(&cfg) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | preset := db.LocalPreset{ 24 | Name: "test", 25 | Preset: db.Preset{ 26 | Name: "test", 27 | }, 28 | } 29 | err = repo.CreateLocalPreset(&preset) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | client := repo.(*redisRepository).storage.RedisClient() 34 | defer client.Close() 35 | items, err := client.HGetAll("localpreset:" + preset.Name).Result() 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | expectedItems := map[string]string{ 40 | "preset_name": "test", 41 | "preset_twopass": "false", 42 | } 43 | if !reflect.DeepEqual(items, expectedItems) { 44 | t.Errorf("Wrong preset hash returned from Redis. Want %#v. Got %#v", expectedItems, items) 45 | } 46 | } 47 | 48 | func TestCreateLocalPresetDuplicate(t *testing.T) { 49 | err := cleanRedis() 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | preset := db.LocalPreset{ 58 | Name: "test", 59 | Preset: db.Preset{ 60 | Name: "test", 61 | }, 62 | } 63 | err = repo.CreateLocalPreset(&preset) 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | 68 | err = repo.CreateLocalPreset(&preset) 69 | if err != db.ErrLocalPresetAlreadyExists { 70 | t.Errorf("Got wrong error. Want %#v. Got %#v", db.ErrLocalPresetAlreadyExists, err) 71 | } 72 | } 73 | 74 | func TestUpdateLocalPreset(t *testing.T) { 75 | err := cleanRedis() 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | preset := db.LocalPreset{ 84 | Name: "test", 85 | Preset: db.Preset{ 86 | Name: "test", 87 | }, 88 | } 89 | err = repo.CreateLocalPreset(&preset) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | preset.Preset.Name = "test-different" 94 | 95 | err = repo.UpdateLocalPreset(&preset) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | client := repo.(*redisRepository).storage.RedisClient() 100 | defer client.Close() 101 | items, err := client.HGetAll("localpreset:" + preset.Name).Result() 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | expectedItems := map[string]string{ 106 | "preset_name": "test-different", 107 | "preset_twopass": "false", 108 | } 109 | if !reflect.DeepEqual(items, expectedItems) { 110 | t.Errorf("Wrong presetmap hash returned from Redis. Want %#v. Got %#v", expectedItems, items) 111 | } 112 | } 113 | 114 | func TestUpdateLocalPresetNotFound(t *testing.T) { 115 | err := cleanRedis() 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 120 | if err != nil { 121 | t.Fatal(err) 122 | } 123 | err = repo.UpdateLocalPreset(&db.LocalPreset{ 124 | Name: "non-existent", 125 | Preset: db.Preset{ 126 | Name: "test", 127 | }, 128 | }) 129 | if err != db.ErrLocalPresetNotFound { 130 | t.Errorf("Wrong error returned by UpdateLocalPreset. Want ErrLocalPresetNotFound. Got %#v.", err) 131 | } 132 | } 133 | 134 | func TestDeleteLocalPreset(t *testing.T) { 135 | err := cleanRedis() 136 | if err != nil { 137 | t.Fatal(err) 138 | } 139 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 140 | if err != nil { 141 | t.Fatal(err) 142 | } 143 | 144 | preset := db.LocalPreset{ 145 | Name: "test", 146 | Preset: db.Preset{ 147 | Name: "test", 148 | }, 149 | } 150 | err = repo.CreateLocalPreset(&preset) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | err = repo.DeleteLocalPreset(&db.LocalPreset{Name: preset.Name}) 155 | if err != nil { 156 | t.Fatal(err) 157 | } 158 | client := repo.(*redisRepository).storage.RedisClient() 159 | result := client.HGetAll("localpreset:test") 160 | if len(result.Val()) != 0 { 161 | t.Errorf("Unexpected value after delete call: %v", result.Val()) 162 | } 163 | } 164 | 165 | func TestDeleteLocalPresetNotFound(t *testing.T) { 166 | err := cleanRedis() 167 | if err != nil { 168 | t.Fatal(err) 169 | } 170 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 171 | if err != nil { 172 | t.Fatal(err) 173 | } 174 | err = repo.DeleteLocalPreset(&db.LocalPreset{Name: "non-existent"}) 175 | if err != db.ErrLocalPresetNotFound { 176 | t.Errorf("Wrong error returned by DeleteLocalPreset. Want ErrLocalPresetNotFound. Got %#v.", err) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /db/redis/presetmap.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "github.com/go-redis/redis" 5 | "github.com/video-dev/video-transcoding-api/v2/db" 6 | "github.com/video-dev/video-transcoding-api/v2/db/redis/storage" 7 | ) 8 | 9 | const presetmapsSetKey = "presetmaps" 10 | 11 | func (r *redisRepository) CreatePresetMap(presetMap *db.PresetMap) error { 12 | if _, err := r.GetPresetMap(presetMap.Name); err == nil { 13 | return db.ErrPresetMapAlreadyExists 14 | } 15 | return r.savePresetMap(presetMap) 16 | } 17 | 18 | func (r *redisRepository) UpdatePresetMap(presetMap *db.PresetMap) error { 19 | if _, err := r.GetPresetMap(presetMap.Name); err == db.ErrPresetMapNotFound { 20 | return err 21 | } 22 | return r.savePresetMap(presetMap) 23 | } 24 | 25 | func (r *redisRepository) savePresetMap(presetMap *db.PresetMap) error { 26 | fields, err := r.storage.FieldMap(presetMap) 27 | if err != nil { 28 | return err 29 | } 30 | presetMapKey := r.presetMapKey(presetMap.Name) 31 | return r.storage.RedisClient().Watch(func(tx *redis.Tx) error { 32 | err := tx.HMSet(presetMapKey, fields).Err() 33 | if err != nil { 34 | return err 35 | } 36 | return tx.SAdd(presetmapsSetKey, presetMap.Name).Err() 37 | }, presetMapKey) 38 | } 39 | 40 | func (r *redisRepository) DeletePresetMap(presetMap *db.PresetMap) error { 41 | err := r.storage.Delete(r.presetMapKey(presetMap.Name)) 42 | if err != nil { 43 | if err == storage.ErrNotFound { 44 | return db.ErrPresetMapNotFound 45 | } 46 | return err 47 | } 48 | r.storage.RedisClient().SRem(presetmapsSetKey, presetMap.Name) 49 | return nil 50 | } 51 | 52 | func (r *redisRepository) GetPresetMap(name string) (*db.PresetMap, error) { 53 | presetMap := db.PresetMap{Name: name, ProviderMapping: make(map[string]string)} 54 | err := r.storage.Load(r.presetMapKey(name), &presetMap) 55 | if err == storage.ErrNotFound { 56 | return nil, db.ErrPresetMapNotFound 57 | } 58 | return &presetMap, err 59 | } 60 | 61 | func (r *redisRepository) ListPresetMaps() ([]db.PresetMap, error) { 62 | presetMapNames, err := r.storage.RedisClient().SMembers(presetmapsSetKey).Result() 63 | if err != nil { 64 | return nil, err 65 | } 66 | presetsMap := make([]db.PresetMap, 0, len(presetMapNames)) 67 | for _, name := range presetMapNames { 68 | presetMap, err := r.GetPresetMap(name) 69 | if err != nil && err != db.ErrPresetMapNotFound { 70 | return nil, err 71 | } 72 | if presetMap != nil { 73 | presetsMap = append(presetsMap, *presetMap) 74 | } 75 | } 76 | return presetsMap, nil 77 | } 78 | 79 | func (r *redisRepository) presetMapKey(name string) string { 80 | return "presetmap:" + name 81 | } 82 | -------------------------------------------------------------------------------- /db/redis/presetmap_test.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/video-dev/video-transcoding-api/v2/config" 8 | "github.com/video-dev/video-transcoding-api/v2/db" 9 | "github.com/video-dev/video-transcoding-api/v2/db/redis/storage" 10 | ) 11 | 12 | func TestCreatePresetMap(t *testing.T) { 13 | err := cleanRedis() 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | var cfg config.Config 18 | cfg.Redis = new(storage.Config) 19 | repo, err := NewRepository(&cfg) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | presetmap := db.PresetMap{ 24 | Name: "mypreset", 25 | ProviderMapping: map[string]string{ 26 | "elementalconductor": "abc123", 27 | "elastictranscoder": "1281742-93939", 28 | }, 29 | OutputOpts: db.OutputOptions{Extension: "ts"}, 30 | } 31 | err = repo.CreatePresetMap(&presetmap) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | client := repo.(*redisRepository).storage.RedisClient() 36 | defer client.Close() 37 | items, err := client.HGetAll("presetmap:" + presetmap.Name).Result() 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | expectedItems := map[string]string{ 42 | "pmapping_elementalconductor": "abc123", 43 | "pmapping_elastictranscoder": "1281742-93939", 44 | "output_extension": "ts", 45 | "presetmap_name": "mypreset", 46 | } 47 | if !reflect.DeepEqual(items, expectedItems) { 48 | t.Errorf("Wrong presetmap hash returned from Redis. Want %#v. Got %#v", expectedItems, items) 49 | } 50 | } 51 | 52 | func TestCreatePresetMapDuplicate(t *testing.T) { 53 | err := cleanRedis() 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | presetmap := db.PresetMap{ 62 | Name: "mypreset", 63 | ProviderMapping: map[string]string{"elemental": "123"}, 64 | } 65 | err = repo.CreatePresetMap(&presetmap) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | err = repo.CreatePresetMap(&presetmap) 70 | if err != db.ErrPresetMapAlreadyExists { 71 | t.Errorf("Got wrong error. Want %#v. Got %#v", db.ErrPresetMapAlreadyExists, err) 72 | } 73 | } 74 | 75 | func TestUpdatePresetMap(t *testing.T) { 76 | err := cleanRedis() 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | presetmap := db.PresetMap{Name: "mypresetmap", ProviderMapping: map[string]string{"elemental": "abc123"}} 85 | err = repo.CreatePresetMap(&presetmap) 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | presetmap.ProviderMapping = map[string]string{ 90 | "elemental": "abc1234", 91 | "elastictranscoder": "def123", 92 | } 93 | presetmap.OutputOpts = db.OutputOptions{Extension: "mp4"} 94 | err = repo.UpdatePresetMap(&presetmap) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | client := repo.(*redisRepository).storage.RedisClient() 99 | defer client.Close() 100 | items, err := client.HGetAll("presetmap:" + presetmap.Name).Result() 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | expectedItems := map[string]string{ 105 | "pmapping_elemental": "abc1234", 106 | "pmapping_elastictranscoder": "def123", 107 | "output_extension": "mp4", 108 | "presetmap_name": "mypresetmap", 109 | } 110 | if !reflect.DeepEqual(items, expectedItems) { 111 | t.Errorf("Wrong presetmap hash returned from Redis. Want %#v. Got %#v", expectedItems, items) 112 | } 113 | } 114 | 115 | func TestUpdatePresetMapNotFound(t *testing.T) { 116 | err := cleanRedis() 117 | if err != nil { 118 | t.Fatal(err) 119 | } 120 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 121 | if err != nil { 122 | t.Fatal(err) 123 | } 124 | err = repo.UpdatePresetMap(&db.PresetMap{Name: "mypresetmap"}) 125 | if err != db.ErrPresetMapNotFound { 126 | t.Errorf("Wrong error returned by UpdatePresetMap. Want ErrPresetMapNotFound. Got %#v.", err) 127 | } 128 | } 129 | 130 | func TestDeletePresetMap(t *testing.T) { 131 | err := cleanRedis() 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 136 | if err != nil { 137 | t.Fatal(err) 138 | } 139 | presetmap := db.PresetMap{Name: "mypresetmap", ProviderMapping: map[string]string{"elemental": "abc123"}} 140 | err = repo.CreatePresetMap(&presetmap) 141 | if err != nil { 142 | t.Fatal(err) 143 | } 144 | err = repo.DeletePresetMap(&db.PresetMap{Name: presetmap.Name}) 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | client := repo.(*redisRepository).storage.RedisClient() 149 | result := client.HGetAll("presetmap:mypresetmap") 150 | if len(result.Val()) != 0 { 151 | t.Errorf("Unexpected value after delete call: %v", result.Val()) 152 | } 153 | } 154 | 155 | func TestDeletePresetMapNotFound(t *testing.T) { 156 | err := cleanRedis() 157 | if err != nil { 158 | t.Fatal(err) 159 | } 160 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 161 | if err != nil { 162 | t.Fatal(err) 163 | } 164 | err = repo.DeletePresetMap(&db.PresetMap{Name: "mypresetmap"}) 165 | if err != db.ErrPresetMapNotFound { 166 | t.Errorf("Wrong error returned by DeletePresetMap. Want ErrPresetMapNotFound. Got %#v.", err) 167 | } 168 | } 169 | 170 | func TestGetPresetMap(t *testing.T) { 171 | err := cleanRedis() 172 | if err != nil { 173 | t.Fatal(err) 174 | } 175 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 176 | if err != nil { 177 | t.Fatal(err) 178 | } 179 | presetmap := db.PresetMap{ 180 | Name: "mypresetmap", 181 | ProviderMapping: map[string]string{ 182 | "elementalconductor": "abc-123", 183 | "elastictranscoder": "0129291-0001", 184 | "encoding.com": "wait what?", 185 | }, 186 | OutputOpts: db.OutputOptions{Extension: "ts"}, 187 | } 188 | err = repo.CreatePresetMap(&presetmap) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | gotPresetMap, err := repo.GetPresetMap(presetmap.Name) 193 | if err != nil { 194 | t.Fatal(err) 195 | } 196 | if !reflect.DeepEqual(*gotPresetMap, presetmap) { 197 | t.Errorf("Wrong preset. Want %#v. Got %#v.", presetmap, *gotPresetMap) 198 | } 199 | } 200 | 201 | func TestGetPresetMapNotFound(t *testing.T) { 202 | err := cleanRedis() 203 | if err != nil { 204 | t.Fatal(err) 205 | } 206 | repo, err := NewRepository(&config.Config{Redis: new(storage.Config)}) 207 | if err != nil { 208 | t.Fatal(err) 209 | } 210 | gotPresetMap, err := repo.GetPresetMap("mypresetmap") 211 | if err != db.ErrPresetMapNotFound { 212 | t.Errorf("Wrong error returned. Want ErrPresetMapNotFound. Got %#v.", err) 213 | } 214 | if gotPresetMap != nil { 215 | t.Errorf("Unexpected non-nil presetmap: %#v.", gotPresetMap) 216 | } 217 | } 218 | 219 | func TestListPresetMaps(t *testing.T) { 220 | err := cleanRedis() 221 | if err != nil { 222 | t.Fatal(err) 223 | } 224 | var cfg config.Config 225 | cfg.Redis = new(storage.Config) 226 | repo, err := NewRepository(&cfg) 227 | if err != nil { 228 | t.Fatal(err) 229 | } 230 | presetmaps := []db.PresetMap{ 231 | { 232 | Name: "presetmap-1", 233 | ProviderMapping: map[string]string{ 234 | "elementalconductor": "abc123", 235 | "elastictranscoder": "1281742-93939", 236 | }, 237 | OutputOpts: db.OutputOptions{Extension: "mp4"}, 238 | }, 239 | { 240 | Name: "presetmap-2", 241 | ProviderMapping: map[string]string{ 242 | "elementalconductor": "abc124", 243 | "elastictranscoder": "1281743-93939", 244 | }, 245 | OutputOpts: db.OutputOptions{Extension: "webm"}, 246 | }, 247 | { 248 | Name: "presetmap-3", 249 | ProviderMapping: map[string]string{ 250 | "elementalconductor": "abc125", 251 | "elastictranscoder": "1281744-93939", 252 | }, 253 | OutputOpts: db.OutputOptions{Extension: "ts"}, 254 | }, 255 | } 256 | for i := range presetmaps { 257 | err = repo.CreatePresetMap(&presetmaps[i]) 258 | if err != nil { 259 | t.Fatal(err) 260 | } 261 | } 262 | gotPresetMaps, err := repo.ListPresetMaps() 263 | if err != nil { 264 | t.Fatal(err) 265 | } 266 | 267 | // Why? The "list" of IDs is a set on Redis, so we need to make sure 268 | // that order is not important before invoking reflect.DeepEqual. 269 | expected := presetListToMap(presetmaps) 270 | got := presetListToMap(gotPresetMaps) 271 | 272 | if !reflect.DeepEqual(got, expected) { 273 | t.Errorf("ListPresetMaps(): wrong list. Want %#v. Got %#v.", presetmaps, gotPresetMaps) 274 | } 275 | } 276 | 277 | func presetListToMap(presetmaps []db.PresetMap) map[string]db.PresetMap { 278 | result := make(map[string]db.PresetMap, len(presetmaps)) 279 | for _, presetmap := range presetmaps { 280 | result[presetmap.Name] = presetmap 281 | } 282 | return result 283 | } 284 | -------------------------------------------------------------------------------- /db/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "github.com/video-dev/video-transcoding-api/v2/config" 5 | "github.com/video-dev/video-transcoding-api/v2/db" 6 | "github.com/video-dev/video-transcoding-api/v2/db/redis/storage" 7 | ) 8 | 9 | // NewRepository creates a new Repository that uses Redis for persistence. 10 | func NewRepository(cfg *config.Config) (db.Repository, error) { 11 | s, err := storage.NewStorage(cfg.Redis) 12 | if err != nil { 13 | return nil, err 14 | } 15 | return &redisRepository{config: cfg, storage: s}, nil 16 | } 17 | 18 | type redisRepository struct { 19 | config *config.Config 20 | storage *storage.Storage 21 | } 22 | -------------------------------------------------------------------------------- /db/redis/redis_test.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import "github.com/go-redis/redis" 4 | 5 | func cleanRedis() error { 6 | client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}) 7 | defer client.Close() 8 | err := deleteKeys("job:*", client) 9 | if err != nil { 10 | return err 11 | } 12 | err = deleteKeys("presetmap:*", client) 13 | if err != nil { 14 | return err 15 | } 16 | err = deleteKeys("localpreset:*", client) 17 | if err != nil { 18 | return err 19 | } 20 | err = deleteKeys(presetmapsSetKey, client) 21 | if err != nil { 22 | return err 23 | } 24 | err = deleteKeys(localPresetsSetKey, client) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | return deleteKeys(jobsSetKey, client) 30 | } 31 | 32 | func deleteKeys(pattern string, client *redis.Client) error { 33 | keys, err := client.Keys(pattern).Result() 34 | if err != nil { 35 | return err 36 | } 37 | if len(keys) > 0 { 38 | _, err = client.Del(keys...).Result() 39 | } 40 | return err 41 | } 42 | -------------------------------------------------------------------------------- /db/redis/storage/redis.go: -------------------------------------------------------------------------------- 1 | // Package storage provides a type for storing Go objects in Redis. 2 | package storage 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/go-redis/redis" 14 | ) 15 | 16 | // ErrNotFound is the error returned when the given key is not found. 17 | var ErrNotFound = errors.New("not found") 18 | 19 | // Storage is the basic type that provides methods for saving, listing and 20 | // deleting types on Redis. 21 | type Storage struct { 22 | once sync.Once 23 | config *Config 24 | client *redis.Client 25 | } 26 | 27 | // Config contains configuration for the Redis, in the standard proposed by 28 | // Gizmo. 29 | type Config struct { 30 | // Comma-separated list of sentinel servers. 31 | // 32 | // Example: 10.10.10.10:6379,10.10.10.1:6379,10.10.10.2:6379. 33 | SentinelAddrs string `envconfig:"SENTINEL_ADDRS"` 34 | SentinelMasterName string `envconfig:"SENTINEL_MASTER_NAME"` 35 | 36 | RedisAddr string `envconfig:"REDIS_ADDR" default:"127.0.0.1:6379"` 37 | Password string `envconfig:"REDIS_PASSWORD"` 38 | PoolSize int `envconfig:"REDIS_POOL_SIZE"` 39 | PoolTimeout int `envconfig:"REDIS_POOL_TIMEOUT_SECONDS"` 40 | IdleTimeout int `envconfig:"REDIS_IDLE_TIMEOUT_SECONDS"` 41 | IdleCheckFrequency int `envconfig:"REDIS_IDLE_CHECK_FREQUENCY_SECONDS"` 42 | } 43 | 44 | // RedisClient creates a new instance of the client using the underlying 45 | // configuration. 46 | func (c *Config) RedisClient() *redis.Client { 47 | if c.SentinelAddrs != "" { 48 | sentinelAddrs := strings.Split(c.SentinelAddrs, ",") 49 | return redis.NewFailoverClient(&redis.FailoverOptions{ 50 | SentinelAddrs: sentinelAddrs, 51 | MasterName: c.SentinelMasterName, 52 | Password: c.Password, 53 | PoolSize: c.PoolSize, 54 | PoolTimeout: time.Duration(c.PoolTimeout) * time.Second, 55 | IdleTimeout: time.Duration(c.IdleTimeout) * time.Second, 56 | IdleCheckFrequency: time.Duration(c.IdleCheckFrequency) * time.Second, 57 | }) 58 | } 59 | redisAddr := c.RedisAddr 60 | if redisAddr == "" { 61 | redisAddr = "127.0.0.1:6379" 62 | } 63 | return redis.NewClient(&redis.Options{ 64 | Addr: redisAddr, 65 | Password: c.Password, 66 | PoolSize: c.PoolSize, 67 | PoolTimeout: time.Duration(c.PoolTimeout) * time.Second, 68 | IdleTimeout: time.Duration(c.IdleTimeout) * time.Second, 69 | IdleCheckFrequency: time.Duration(c.IdleCheckFrequency) * time.Second, 70 | }) 71 | } 72 | 73 | // NewStorage returns a new instance of storage with the given configuration. 74 | func NewStorage(cfg *Config) (*Storage, error) { 75 | return &Storage{config: cfg}, nil 76 | } 77 | 78 | // Save creates the given key as a Redis hash. 79 | // 80 | // The given hash must be either a struct or map[string]string. 81 | func (s *Storage) Save(key string, hash interface{}) error { 82 | fields, err := s.FieldMap(hash) 83 | if err != nil { 84 | return err 85 | } 86 | return s.RedisClient().HMSet(key, fields).Err() 87 | } 88 | 89 | // FieldMap extract the map of fields from the given type (which can be a 90 | // struct, a map[string]string or pointer to those). 91 | func (s *Storage) FieldMap(hash interface{}) (map[string]interface{}, error) { 92 | if hash == nil { 93 | return nil, errors.New("no fields provided") 94 | } 95 | value := reflect.ValueOf(hash) 96 | if value.Kind() == reflect.Ptr { 97 | value = value.Elem() 98 | } 99 | switch value.Kind() { 100 | case reflect.Map: 101 | return s.mapToFieldList(hash) 102 | case reflect.Struct: 103 | return s.structToFieldList(value) 104 | default: 105 | return nil, errors.New("please provide a map or a struct") 106 | } 107 | } 108 | 109 | func (s *Storage) mapToFieldList(hash interface{}, prefixes ...string) (map[string]interface{}, error) { 110 | m, ok := hash.(map[string]string) 111 | if !ok { 112 | return nil, errors.New("please provide a map[string]string") 113 | } 114 | if len(m) < 1 { 115 | return nil, errors.New("please provide a map[string]string with at least one item") 116 | } 117 | fields := make(map[string]interface{}, len(m)) 118 | for key, value := range m { 119 | key = strings.Join(append(prefixes, key), "_") 120 | fields[key] = value 121 | } 122 | return fields, nil 123 | } 124 | 125 | func (s *Storage) structToFieldList(value reflect.Value, prefixes ...string) (map[string]interface{}, error) { 126 | fields := make(map[string]interface{}) 127 | for i := 0; i < value.NumField(); i++ { 128 | field := value.Type().Field(i) 129 | if field.PkgPath != "" { 130 | continue 131 | } 132 | fieldName := field.Tag.Get("redis-hash") 133 | if fieldName == "-" { 134 | continue 135 | } 136 | parts := strings.Split(fieldName, ",") 137 | fieldValue := value.Field(i) 138 | if len(parts) > 1 && parts[len(parts)-1] == "expand" { 139 | if fieldValue.Kind() == reflect.Ptr { 140 | fieldValue = fieldValue.Elem() 141 | } 142 | myPrefixes := append(prefixes, parts[0]) 143 | switch fieldValue.Kind() { 144 | case reflect.Struct: 145 | expandedFields, err := s.structToFieldList(fieldValue, myPrefixes...) 146 | if err != nil { 147 | return nil, err 148 | } 149 | for k, v := range expandedFields { 150 | fields[k] = v 151 | } 152 | case reflect.Map: 153 | expandedFields, err := s.mapToFieldList(fieldValue.Interface(), myPrefixes...) 154 | if err != nil { 155 | return nil, err 156 | } 157 | for k, v := range expandedFields { 158 | fields[k] = v 159 | } 160 | default: 161 | return nil, errors.New("can only expand structs and maps") 162 | } 163 | } else if parts[0] != "" { 164 | key := strings.Join(append(prefixes, parts[0]), "_") 165 | var strValue string 166 | iface := fieldValue.Interface() 167 | switch v := iface.(type) { 168 | case time.Time: 169 | strValue = v.Format(time.RFC3339Nano) 170 | case []string: 171 | strValue = strings.Join(v, "%%%") 172 | default: 173 | strValue = fmt.Sprintf("%v", v) 174 | } 175 | if parts[len(parts)-1] == "omitempty" && strValue == "" { 176 | continue 177 | } 178 | fields[key] = strValue 179 | } 180 | } 181 | return fields, nil 182 | } 183 | 184 | // Load loads the given key in the given output. The output must be a pointer 185 | // to a struct or a map[string]string. 186 | func (s *Storage) Load(key string, out interface{}) error { 187 | value := reflect.ValueOf(out) 188 | if value.Kind() != reflect.Ptr { 189 | return errors.New("please provide a pointer for getting result from the database") 190 | } 191 | value = value.Elem() 192 | result, err := s.RedisClient().HGetAll(key).Result() 193 | if err != nil { 194 | return err 195 | } 196 | if len(result) < 1 { 197 | return ErrNotFound 198 | } 199 | switch value.Kind() { 200 | case reflect.Map: 201 | return s.loadMap(result, value) 202 | case reflect.Struct: 203 | return s.loadStruct(result, value) 204 | default: 205 | return errors.New("please provider a pointer to a struct or a map for getting result from the database") 206 | } 207 | } 208 | 209 | func (s *Storage) loadMap(in map[string]string, out reflect.Value, prefixes ...string) error { 210 | if out.Type().Key().Kind() != reflect.String || out.Type().Elem().Kind() != reflect.String { 211 | return errors.New("please provide a map[string]string") 212 | } 213 | joinedPrefixes := strings.Join(prefixes, "_") 214 | if joinedPrefixes != "" { 215 | joinedPrefixes += "_" 216 | } 217 | for k, v := range in { 218 | if !strings.HasPrefix(k, joinedPrefixes) { 219 | continue 220 | } 221 | k = strings.Replace(k, joinedPrefixes, "", 1) 222 | out.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(v)) 223 | } 224 | return nil 225 | } 226 | 227 | func (s *Storage) loadStruct(in map[string]string, out reflect.Value, prefixes ...string) error { 228 | for i := 0; i < out.NumField(); i++ { 229 | field := out.Type().Field(i) 230 | if field.PkgPath != "" { 231 | continue 232 | } 233 | tagValue := field.Tag.Get("redis-hash") 234 | if tagValue == "-" { 235 | continue 236 | } 237 | parts := strings.Split(tagValue, ",") 238 | fieldValue := out.Field(i) 239 | if len(parts) > 1 && parts[len(parts)-1] == "expand" { 240 | myPrefixes := append(prefixes, parts[0]) 241 | if fieldValue.Kind() == reflect.Ptr { 242 | fieldValue = fieldValue.Elem() 243 | } 244 | switch fieldValue.Kind() { 245 | case reflect.Map: 246 | err := s.loadMap(in, fieldValue, myPrefixes...) 247 | if err != nil { 248 | return err 249 | } 250 | case reflect.Struct: 251 | err := s.loadStruct(in, fieldValue, myPrefixes...) 252 | if err != nil { 253 | return err 254 | } 255 | default: 256 | return errors.New("can only expand values to structs or maps") 257 | } 258 | } else { 259 | key := strings.Join(append(prefixes, parts[0]), "_") 260 | if value, ok := in[key]; ok { 261 | switch fieldValue.Kind() { 262 | case reflect.Slice: 263 | values := strings.Split(value, "%%%") 264 | if reflect.TypeOf(values).AssignableTo(fieldValue.Type()) { 265 | fieldValue.Set(reflect.ValueOf(values)) 266 | } 267 | case reflect.Bool: 268 | boolValue, err := strconv.ParseBool(value) 269 | if err != nil { 270 | return err 271 | } 272 | fieldValue.SetBool(boolValue) 273 | case reflect.Float64: 274 | floatValue, err := strconv.ParseFloat(value, 64) 275 | if err != nil { 276 | return err 277 | } 278 | fieldValue.SetFloat(floatValue) 279 | case reflect.Int: 280 | intValue, err := strconv.ParseInt(value, 10, 64) 281 | if err != nil { 282 | return err 283 | } 284 | fieldValue.SetInt(intValue) 285 | case reflect.Uint: 286 | uintValue, err := strconv.ParseUint(value, 10, 64) 287 | if err != nil { 288 | return err 289 | } 290 | fieldValue.SetUint(uintValue) 291 | case reflect.Struct: 292 | if reflect.TypeOf(time.Time{}).AssignableTo(fieldValue.Type()) { 293 | timeValue, err := time.Parse(time.RFC3339Nano, value) 294 | if err != nil { 295 | return err 296 | } 297 | fieldValue.Set(reflect.ValueOf(timeValue)) 298 | } 299 | default: 300 | fieldValue.SetString(value) 301 | } 302 | } 303 | } 304 | } 305 | return nil 306 | } 307 | 308 | // Delete deletes the given key from redis, returning ErrNotFound when it 309 | // doesn't exist. 310 | func (s *Storage) Delete(key string) error { 311 | n, err := s.RedisClient().Del(key).Result() 312 | if err != nil { 313 | return err 314 | } 315 | if n == 0 { 316 | return ErrNotFound 317 | } 318 | return nil 319 | } 320 | 321 | // RedisClient returns the underlying Redis client. 322 | func (s *Storage) RedisClient() *redis.Client { 323 | s.once.Do(func() { 324 | s.client = s.config.RedisClient() 325 | }) 326 | return s.client 327 | } 328 | -------------------------------------------------------------------------------- /db/redis/storage/stub_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "time" 4 | 5 | type Person struct { 6 | ID string `redis-hash:"-"` 7 | Name string `redis-hash:"name"` 8 | Address Address `redis-hash:"address,expand"` 9 | Age uint `redis-hash:"age"` 10 | Weight float64 `redis-hash:"weight"` 11 | BirthTime time.Time `redis-hash:"birth"` 12 | PreferredColors []string `redis-hash:"colors"` 13 | NonTagged string 14 | unexported string 15 | unexportedTagged string `redis-hash:"unexported"` 16 | } 17 | 18 | type Address struct { 19 | Data map[string]string `redis-hash:"data,expand"` 20 | Number int `redis-hash:"number"` 21 | Main bool `redis-hash:"main"` 22 | City *City `redis-hash:"city,expand"` 23 | } 24 | 25 | type City struct { 26 | Name string `redis-hash:"name"` 27 | } 28 | 29 | type InvalidStruct struct { 30 | Name string `redis-hash:"name,expand"` 31 | } 32 | 33 | type InvalidInnerStruct struct { 34 | Data map[string]int `redis-hash:"data,expand"` 35 | } 36 | 37 | type Job struct { 38 | ID string `redis-hash:"jobID"` 39 | ProviderName string `redis-hash:"providerName"` 40 | ProviderJobID string `redis-hash:"providerJobID"` 41 | StreamingParams StreamingParams `redis-hash:"streamingparams,expand"` 42 | CreationTime time.Time `redis-hash:"creationTime"` 43 | SourceMedia string `redis-hash:"source"` 44 | Outputs []TranscodeOutput `redis-hash:"-"` 45 | } 46 | 47 | type TranscodeOutput struct { 48 | Preset PresetMap `redis-hash:"presetmap,expand"` 49 | FileName string `redis-hash:"filename"` 50 | } 51 | 52 | type PresetMap struct { 53 | Name string `redis-hash:"presetmap_name"` 54 | } 55 | 56 | type StreamingParams struct { 57 | SegmentDuration uint `redis-hash:"segmentDuration"` 58 | Protocol string `redis-hash:"protocol"` 59 | PlaylistFileName string `redis-hash:"playlistFileName"` 60 | } 61 | 62 | type LocalPreset struct { 63 | Name string `redis-hash:"-"` 64 | Preset Preset `redis-hash:"preset,expand"` 65 | } 66 | 67 | type Preset struct { 68 | Name string `redis-hash:"name"` 69 | Description string `redis-hash:"description,omitempty"` 70 | Container string `redis-hash:"container,omitempty"` 71 | RateControl string `redis-hash:"ratecontrol,omitempty"` 72 | Video VideoPreset `redis-hash:"video,expand"` 73 | Audio AudioPreset `redis-hash:"audio,expand"` 74 | } 75 | 76 | type VideoPreset struct { 77 | Profile string `redis-hash:"profile,omitempty"` 78 | ProfileLevel string `redis-hash:"profilelevel,omitempty"` 79 | Width string `redis-hash:"width,omitempty"` 80 | Height string `redis-hash:"height,omitempty"` 81 | Codec string `redis-hash:"codec,omitempty"` 82 | Bitrate string `redis-hash:"bitrate,omitempty"` 83 | GopSize string `redis-hash:"gopsize,omitempty"` 84 | GopMode string `redis-hash:"gopmode,omitempty"` 85 | InterlaceMode string `redis-hash:"interlacemode,omitempty"` 86 | } 87 | 88 | type AudioPreset struct { 89 | Codec string `redis-hash:"codec,omitempty"` 90 | Bitrate string `redis-hash:"bitrate,omitempty"` 91 | } 92 | -------------------------------------------------------------------------------- /db/redis/storage/testdata/sentinel.conf: -------------------------------------------------------------------------------- 1 | sentinel monitor mymaster 127.0.0.1 6379 2 2 | sentinel down-after-milliseconds mymaster 60000 3 | sentinel failover-timeout mymaster 180000 4 | sentinel parallel-syncs mymaster 1 5 | 6 | port %s 7 | -------------------------------------------------------------------------------- /db/repo.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | var ( 9 | // ErrJobNotFound is the error returned when the job is not found on GetJob or 10 | // DeleteJob. 11 | ErrJobNotFound = errors.New("job not found") 12 | 13 | // ErrPresetMapNotFound is the error returned when the presetmap is not found 14 | // on GetPresetMap, UpdatePresetMap or DeletePresetMap. 15 | ErrPresetMapNotFound = errors.New("presetmap not found") 16 | 17 | // ErrPresetMapAlreadyExists is the error returned when the presetmap already 18 | // exists. 19 | ErrPresetMapAlreadyExists = errors.New("presetmap already exists") 20 | 21 | // ErrLocalPresetNotFound is the error returned when the local preset is not found 22 | // on GetPresetMap, UpdatePresetMap or DeletePresetMap. 23 | ErrLocalPresetNotFound = errors.New("local preset not found") 24 | 25 | // ErrLocalPresetAlreadyExists is the error returned when the local preset already 26 | // exists. 27 | ErrLocalPresetAlreadyExists = errors.New("local preset already exists") 28 | ) 29 | 30 | // Repository represents the repository for persisting types of the API. 31 | type Repository interface { 32 | JobRepository 33 | PresetMapRepository 34 | LocalPresetRepository 35 | } 36 | 37 | // JobRepository is the interface that defines the set of methods for managing Job 38 | // persistence. 39 | type JobRepository interface { 40 | CreateJob(*Job) error 41 | DeleteJob(*Job) error 42 | GetJob(id string) (*Job, error) 43 | ListJobs(JobFilter) ([]Job, error) 44 | } 45 | 46 | // JobFilter contains a set of parameters for filtering the list of jobs in 47 | // JobRepository. 48 | type JobFilter struct { 49 | // Filter jobs since the given time. 50 | Since time.Time 51 | 52 | // Limit the number of jobs in the result. 0 means no limit. 53 | Limit uint 54 | } 55 | 56 | // PresetMapRepository is the interface that defines the set of methods for 57 | // managing PresetMap persistence. 58 | type PresetMapRepository interface { 59 | CreatePresetMap(*PresetMap) error 60 | UpdatePresetMap(*PresetMap) error 61 | DeletePresetMap(*PresetMap) error 62 | GetPresetMap(name string) (*PresetMap, error) 63 | ListPresetMaps() ([]PresetMap, error) 64 | } 65 | 66 | // LocalPresetRepository provides an interface that defines the set of methods for 67 | // managing presets when the provider don't have the ability to store/manage it. 68 | type LocalPresetRepository interface { 69 | CreateLocalPreset(*LocalPreset) error 70 | UpdateLocalPreset(*LocalPreset) error 71 | DeleteLocalPreset(*LocalPreset) error 72 | GetLocalPreset(name string) (*LocalPreset, error) 73 | } 74 | -------------------------------------------------------------------------------- /db/types.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | // Job represents the job that is persisted in the repository of the Transcoding 9 | // API. 10 | // 11 | // swagger:model 12 | type Job struct { 13 | // id of the job. It's automatically generated by the API when creating 14 | // a new Job. 15 | // 16 | // unique: true 17 | ID string `redis-hash:"jobID" json:"jobId"` 18 | 19 | // name of the provider 20 | // 21 | // required: true 22 | ProviderName string `redis-hash:"providerName" json:"providerName"` 23 | 24 | // id of the job on the provider 25 | // 26 | // required: true 27 | ProviderJobID string `redis-hash:"providerJobID" json:"providerJobId"` 28 | 29 | // configuration for adaptive streaming jobs 30 | // Defaults to false. 31 | // 32 | // required: false 33 | StreamingParams StreamingParams `redis-hash:"streamingparams,expand" json:"streamingParams,omitempty"` 34 | 35 | // Time of the creation of the job in the API 36 | // 37 | // required: true 38 | CreationTime time.Time `redis-hash:"creationTime" json:"creationTime"` 39 | 40 | // Source of the job 41 | // 42 | // required: true 43 | SourceMedia string `redis-hash:"source" json:"source"` 44 | 45 | // Output list of the given job 46 | // 47 | // required: true 48 | Outputs []TranscodeOutput `redis-hash:"-" json:"outputs"` 49 | } 50 | 51 | // TranscodeOutput represents a transcoding output. It's a combination of the 52 | // preset and the output file name. 53 | type TranscodeOutput struct { 54 | // Presetmap for the output 55 | // 56 | // required: true 57 | Preset PresetMap `redis-hash:"presetmap,expand" json:"presetmap"` 58 | 59 | // Filename for the output 60 | // 61 | // required: true 62 | FileName string `redis-hash:"filename" json:"filename"` 63 | } 64 | 65 | // StreamingParams represents the params necessary to create Adaptive Streaming jobs 66 | // 67 | // swagger:model 68 | type StreamingParams struct { 69 | // duration of the segment 70 | // 71 | // required: true 72 | SegmentDuration uint `redis-hash:"segmentDuration" json:"segmentDuration"` 73 | 74 | // the protocol name (hls or dash) 75 | // 76 | // required: true 77 | Protocol string `redis-hash:"protocol" json:"protocol"` 78 | 79 | // the playlist file name 80 | // required: true 81 | PlaylistFileName string `redis-hash:"playlistFileName" json:"playlistFileName,omitempty"` 82 | } 83 | 84 | // LocalPreset is a struct to persist encoding configurations. Some providers don't have 85 | // the ability to store presets on it's side so we persist locally. 86 | // 87 | // swagger:model 88 | type LocalPreset struct { 89 | // name of the local preset 90 | // 91 | // unique: true 92 | // required: true 93 | Name string `redis-hash:"-" json:"name"` 94 | 95 | // the preset structure 96 | // required: true 97 | Preset Preset `redis-hash:"preset,expand" json:"preset"` 98 | } 99 | 100 | // Preset defines the set of parameters of a given preset 101 | type Preset struct { 102 | Name string `json:"name,omitempty" redis-hash:"name"` 103 | Description string `json:"description,omitempty" redis-hash:"description,omitempty"` 104 | Container string `json:"container,omitempty" redis-hash:"container,omitempty"` 105 | RateControl string `json:"rateControl,omitempty" redis-hash:"ratecontrol,omitempty"` 106 | TwoPass bool `json:"twoPass" redis-hash:"twopass"` 107 | Video VideoPreset `json:"video" redis-hash:"video,expand"` 108 | Audio AudioPreset `json:"audio" redis-hash:"audio,expand"` 109 | } 110 | 111 | // VideoPreset defines the set of parameters for video on a given preset 112 | type VideoPreset struct { 113 | Profile string `json:"profile,omitempty" redis-hash:"profile,omitempty"` 114 | ProfileLevel string `json:"profileLevel,omitempty" redis-hash:"profilelevel,omitempty"` 115 | Width string `json:"width,omitempty" redis-hash:"width,omitempty"` 116 | Height string `json:"height,omitempty" redis-hash:"height,omitempty"` 117 | Codec string `json:"codec,omitempty" redis-hash:"codec,omitempty"` 118 | Bitrate string `json:"bitrate,omitempty" redis-hash:"bitrate,omitempty"` 119 | GopSize string `json:"gopSize,omitempty" redis-hash:"gopsize,omitempty"` 120 | GopMode string `json:"gopMode,omitempty" redis-hash:"gopmode,omitempty"` 121 | InterlaceMode string `json:"interlaceMode,omitempty" redis-hash:"interlacemode,omitempty"` 122 | BFrames string `json:"bframes,omitempty" redis-hash:"bframes,omitempty"` 123 | } 124 | 125 | // AudioPreset defines the set of parameters for audio on a given preset 126 | type AudioPreset struct { 127 | Codec string `json:"codec,omitempty" redis-hash:"codec,omitempty"` 128 | Bitrate string `json:"bitrate,omitempty" redis-hash:"bitrate,omitempty"` 129 | } 130 | 131 | // PresetMap represents the preset that is persisted in the repository of the 132 | // Transcoding API 133 | // 134 | // Each presetmap is just an aggregator of provider presets, where each preset in 135 | // the API maps to a preset on each provider 136 | // 137 | // swagger:model 138 | type PresetMap struct { 139 | // name of the presetmap 140 | // 141 | // unique: true 142 | // required: true 143 | Name string `redis-hash:"presetmap_name" json:"name"` 144 | 145 | // mapping of provider name to provider's internal preset id. 146 | // 147 | // required: true 148 | ProviderMapping map[string]string `redis-hash:"pmapping,expand" json:"providerMapping"` 149 | 150 | // set of options in the output file for this preset. 151 | // 152 | // required: true 153 | OutputOpts OutputOptions `redis-hash:"output,expand" json:"output"` 154 | } 155 | 156 | // OutputOptions is the set of options for the output file. 157 | // 158 | // This type includes only configuration parameters that are not defined in 159 | // providers (like the extension of the output file). 160 | // 161 | // swagger:model 162 | type OutputOptions struct { 163 | // extension for the output file, it's usually attached to the 164 | // container (for example, webm for VP, mp4 for MPEG-4 and ts for HLS). 165 | // 166 | // The dot should not be part of the extension, i.e. use "webm" instead 167 | // of ".webm". 168 | // 169 | // required: true 170 | Extension string `redis-hash:"extension" json:"extension"` 171 | } 172 | 173 | // Validate checks that the OutputOptions object is properly defined. 174 | func (o *OutputOptions) Validate() error { 175 | if o.Extension == "" { 176 | return errors.New("extension is required") 177 | } 178 | return nil 179 | } 180 | -------------------------------------------------------------------------------- /db/types_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestOutputOptionsValidation(t *testing.T) { 9 | tests := []struct { 10 | testCase string 11 | opts OutputOptions 12 | errMsg string 13 | }{ 14 | { 15 | "valid options", 16 | OutputOptions{Extension: "mp4"}, 17 | "", 18 | }, 19 | { 20 | "missing extension", 21 | OutputOptions{Extension: ""}, 22 | "extension is required", 23 | }, 24 | } 25 | for _, test := range tests { 26 | err := test.opts.Validate() 27 | if err == nil { 28 | err = errors.New("") 29 | } 30 | if err.Error() != test.errMsg { 31 | t.Errorf("wrong error message\nWant %q\nGot %q", test.errMsg, err.Error()) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // video-transcoding-api 2 | // 3 | // HTTP API for transcoding media files into different formats using pluggable 4 | // providers. 5 | package main 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/video-dev/video-transcoding-api/v2 2 | 3 | require ( 4 | github.com/NYTimes/gizmo v1.3.6 5 | github.com/NYTimes/gziphandler v1.1.1 6 | github.com/aws/aws-sdk-go-v2 v1.14.0 7 | github.com/aws/aws-sdk-go-v2/config v1.14.0 8 | github.com/aws/aws-sdk-go-v2/credentials v1.9.0 9 | github.com/aws/aws-sdk-go-v2/service/mediaconvert v1.20.0 10 | github.com/bitmovin/bitmovin-go v1.29.0 11 | github.com/fsouza/ctxlogger v1.5.12 12 | github.com/fsouza/gizmo-stackdriver-logging v1.3.3 13 | github.com/go-redis/redis v6.15.9+incompatible 14 | github.com/google/go-cmp v0.5.7 15 | github.com/google/gops v0.3.22 16 | github.com/gorilla/handlers v1.5.1 17 | github.com/hybrik/hybrik-sdk-go v0.0.0-20170516091026-c2eee0e66af9 18 | github.com/kelseyhightower/envconfig v1.4.0 19 | github.com/kr/pretty v0.3.0 20 | github.com/onsi/ginkgo v1.8.0 // indirect 21 | github.com/onsi/gomega v1.5.0 // indirect 22 | github.com/pkg/errors v0.9.1 23 | github.com/sirupsen/logrus v1.8.0 24 | github.com/video-dev/go-elementalconductor v1.1.0 25 | github.com/video-dev/go-encodingcom v1.0.0 26 | github.com/video-dev/zencoder v0.0.0-20161215190743-745874544382 27 | ) 28 | 29 | go 1.14 30 | -------------------------------------------------------------------------------- /internal/provider/description.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | // Description fully describes a provider. 4 | // 5 | // It contains the name of the provider, along with its current heath status 6 | // and its capabilities. 7 | type Description struct { 8 | Name string `json:"name"` 9 | Capabilities Capabilities `json:"capabilities"` 10 | Health Health `json:"health"` 11 | Enabled bool `json:"enabled"` 12 | } 13 | 14 | // Capabilities describes the available features in the provider. It specificie 15 | // which input and output formats the provider supports, along with 16 | // supported destinations. 17 | type Capabilities struct { 18 | InputFormats []string `json:"input"` 19 | OutputFormats []string `json:"output"` 20 | Destinations []string `json:"destinations"` 21 | } 22 | 23 | // Health describes the current health status of the provider. If indicates 24 | // whether the provider is healthy or not, and if it's not healthy, it includes 25 | // a message explaining what's wrong. 26 | type Health struct { 27 | OK bool `json:"ok"` 28 | Message string `json:"message,omitempty"` 29 | } 30 | -------------------------------------------------------------------------------- /internal/provider/elementalconductor/client.go: -------------------------------------------------------------------------------- 1 | package elementalconductor 2 | 3 | import "github.com/video-dev/go-elementalconductor" 4 | 5 | type clientInterface interface { 6 | GetPreset(presetID string) (*elementalconductor.Preset, error) 7 | CreatePreset(preset *elementalconductor.Preset) (*elementalconductor.Preset, error) 8 | DeletePreset(presetID string) error 9 | CreateJob(job *elementalconductor.Job) (*elementalconductor.Job, error) 10 | GetJob(jobID string) (*elementalconductor.Job, error) 11 | CancelJob(jobID string) (*elementalconductor.Job, error) 12 | GetNodes() ([]elementalconductor.Node, error) 13 | GetCloudConfig() (*elementalconductor.CloudConfig, error) 14 | } 15 | -------------------------------------------------------------------------------- /internal/provider/elementalconductor/elementalconductor_fake_transcode_test.go: -------------------------------------------------------------------------------- 1 | package elementalconductor 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/video-dev/go-elementalconductor" 7 | "github.com/video-dev/video-transcoding-api/v2/config" 8 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 9 | ) 10 | 11 | type fakeElementalConductorClient struct { 12 | *elementalconductor.Client 13 | jobs map[string]elementalconductor.Job 14 | canceledJobs []string 15 | } 16 | 17 | func newFakeElementalConductorClient(cfg *config.ElementalConductor) *fakeElementalConductorClient { 18 | return &fakeElementalConductorClient{ 19 | jobs: make(map[string]elementalconductor.Job), 20 | Client: &elementalconductor.Client{ 21 | Host: cfg.Host, 22 | UserLogin: cfg.UserLogin, 23 | APIKey: cfg.APIKey, 24 | AuthExpires: cfg.AuthExpires, 25 | AccessKeyID: cfg.AccessKeyID, 26 | SecretAccessKey: cfg.SecretAccessKey, 27 | Destination: cfg.Destination, 28 | }, 29 | } 30 | } 31 | 32 | func (c *fakeElementalConductorClient) GetPreset(presetID string) (*elementalconductor.Preset, error) { 33 | container := elementalconductor.MPEG4 34 | if strings.Contains(presetID, "hls") { 35 | container = elementalconductor.AppleHTTPLiveStreaming 36 | } 37 | return &elementalconductor.Preset{ 38 | Name: presetID, 39 | Container: string(container), 40 | }, nil 41 | } 42 | 43 | func (c *fakeElementalConductorClient) CreatePreset(preset *elementalconductor.Preset) (*elementalconductor.Preset, error) { 44 | return &elementalconductor.Preset{ 45 | Name: preset.Name, 46 | }, nil 47 | } 48 | 49 | func (c *fakeElementalConductorClient) GetJob(jobID string) (*elementalconductor.Job, error) { 50 | job := c.jobs[jobID] 51 | return &job, nil 52 | } 53 | 54 | func (c *fakeElementalConductorClient) CancelJob(jobID string) (*elementalconductor.Job, error) { 55 | c.canceledJobs = append(c.canceledJobs, jobID) 56 | return &elementalconductor.Job{}, nil 57 | } 58 | 59 | func fakeElementalConductorFactory(cfg *config.Config) (provider.TranscodingProvider, error) { 60 | if cfg.ElementalConductor.Host == "" || cfg.ElementalConductor.UserLogin == "" || 61 | cfg.ElementalConductor.APIKey == "" || cfg.ElementalConductor.AuthExpires == 0 { 62 | return nil, errElementalConductorInvalidConfig 63 | } 64 | client := newFakeElementalConductorClient(cfg.ElementalConductor) 65 | return &elementalConductorProvider{client: client, config: cfg.ElementalConductor}, nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/provider/elementalconductor/fake_server_test.go: -------------------------------------------------------------------------------- 1 | package elementalconductor 2 | 3 | import ( 4 | "encoding/xml" 5 | "net/http" 6 | "net/http/httptest" 7 | 8 | "github.com/video-dev/go-elementalconductor" 9 | ) 10 | 11 | type nodeList struct { 12 | XMLName xml.Name `xml:"node_list"` 13 | Nodes []elementalconductor.Node `xml:"node"` 14 | } 15 | 16 | type ElementalServer struct { 17 | *httptest.Server 18 | nodes *nodeList 19 | config *elementalconductor.CloudConfig 20 | } 21 | 22 | func NewElementalServer(config *elementalconductor.CloudConfig, nodes []elementalconductor.Node) *ElementalServer { 23 | s := ElementalServer{ 24 | nodes: &nodeList{XMLName: xml.Name{Local: "node_list"}, Nodes: nodes}, 25 | config: config, 26 | } 27 | s.Server = httptest.NewServer(&s) 28 | return &s 29 | } 30 | 31 | func (s *ElementalServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 32 | switch r.URL.Path { 33 | case "/api/nodes": 34 | w.Header().Set("Content-Type", "application/xml") 35 | xml.NewEncoder(w).Encode(s.nodes) 36 | case "/api/config/cloud": 37 | w.Header().Set("Content-Type", "application/xml") 38 | xml.NewEncoder(w).Encode(s.config) 39 | default: 40 | http.Error(w, "not found", http.StatusNotFound) 41 | } 42 | } 43 | 44 | func (s *ElementalServer) SetCloudConfig(config *elementalconductor.CloudConfig) { 45 | s.config = config 46 | } 47 | 48 | func (s *ElementalServer) SetNodes(nodes []elementalconductor.Node) { 49 | s.nodes.Nodes = nodes 50 | } 51 | -------------------------------------------------------------------------------- /internal/provider/encodingcom/encodingcom_server_test.go: -------------------------------------------------------------------------------- 1 | package encodingcom 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "net/http/httptest" 12 | "strconv" 13 | "time" 14 | 15 | "github.com/video-dev/go-encodingcom" 16 | ) 17 | 18 | const encodingComDateFormat = "2006-01-02 15:04:05" 19 | 20 | var errMediaNotFound = errors.New("media not found") 21 | 22 | type request struct { 23 | Action string `json:"action"` 24 | Name string `json:"name"` 25 | MediaID string `json:"mediaid"` 26 | Source []string `json:"source"` 27 | Format []encodingcom.Format `json:"format"` 28 | } 29 | 30 | type errorResponse struct { 31 | Message string `json:"message"` 32 | Errors errorList `json:"errors"` 33 | } 34 | 35 | type errorList struct { 36 | Error string `json:"error"` 37 | } 38 | 39 | type fakePreset struct { 40 | Name string 41 | GivenName string 42 | Request request 43 | } 44 | 45 | type fakeMedia struct { 46 | ID string 47 | Request request 48 | Created time.Time 49 | Started time.Time 50 | Finished time.Time 51 | Size string 52 | Rotation int 53 | Status string 54 | } 55 | 56 | // encodingComFakeServer is a fake version of the Encoding.com API. 57 | type encodingComFakeServer struct { 58 | *httptest.Server 59 | medias map[string]*fakeMedia 60 | presets map[string]*fakePreset 61 | status *encodingcom.APIStatusResponse 62 | } 63 | 64 | func newEncodingComFakeServer() *encodingComFakeServer { 65 | server := encodingComFakeServer{ 66 | medias: make(map[string]*fakeMedia), 67 | presets: make(map[string]*fakePreset), 68 | status: &encodingcom.APIStatusResponse{StatusCode: "ok", Status: "Ok"}, 69 | } 70 | server.Server = httptest.NewServer(&server) 71 | return &server 72 | } 73 | 74 | func (s *encodingComFakeServer) SetAPIStatus(status *encodingcom.APIStatusResponse) { 75 | s.status = status 76 | } 77 | 78 | func (s *encodingComFakeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 79 | if r.URL.Path == "/status.php" { 80 | s.apiStatus(w, r) 81 | return 82 | } 83 | requestData := r.FormValue("json") 84 | if requestData == "" { 85 | s.Error(w, "json is required") 86 | return 87 | } 88 | var m map[string]request 89 | err := json.Unmarshal([]byte(requestData), &m) 90 | if err != nil { 91 | s.Error(w, err.Error()) 92 | } 93 | req := m["query"] 94 | switch req.Action { 95 | case "AddMedia": 96 | s.addMedia(w, req) 97 | case "CancelMedia": 98 | s.cancelMedia(w, req) 99 | case "GetStatus": 100 | s.getStatus(w, req) 101 | case "GetMediaInfo": 102 | s.getMediaInfo(w, req) 103 | case "GetPreset": 104 | s.getPreset(w, req) 105 | case "SavePreset": 106 | s.savePreset(w, req) 107 | case "DeletePreset": 108 | s.deletePreset(w, req) 109 | default: 110 | s.Error(w, "invalid action") 111 | } 112 | } 113 | 114 | func (s *encodingComFakeServer) apiStatus(w http.ResponseWriter, _ *http.Request) { 115 | w.Header().Set("Content-Type", "application/json") 116 | json.NewEncoder(w).Encode(s.status) 117 | } 118 | 119 | func (s *encodingComFakeServer) addMedia(w io.Writer, req request) { 120 | id := generateID() 121 | created := time.Now().UTC() 122 | s.medias[id] = &fakeMedia{ 123 | ID: id, 124 | Request: req, 125 | Created: created, 126 | Started: created.Add(time.Second), 127 | Size: "1920x1080", 128 | Rotation: 90, 129 | } 130 | resp := map[string]encodingcom.AddMediaResponse{ 131 | "response": {MediaID: id, Message: "it worked"}, 132 | } 133 | json.NewEncoder(w).Encode(resp) 134 | } 135 | 136 | func (s *encodingComFakeServer) cancelMedia(w io.Writer, req request) { 137 | media, err := s.getMedia(req.MediaID) 138 | if err != nil { 139 | s.Error(w, err.Error()) 140 | return 141 | } 142 | media.Status = "Canceled" 143 | resp := map[string]map[string]interface{}{ 144 | "response": {"message": "Deleted"}, 145 | } 146 | json.NewEncoder(w).Encode(resp) 147 | } 148 | 149 | func (s *encodingComFakeServer) getMediaInfo(w io.Writer, req request) { 150 | media, err := s.getMedia(req.MediaID) 151 | if err != nil { 152 | s.Error(w, err.Error()) 153 | return 154 | } 155 | format := media.Request.Format[0] 156 | resp := map[string]map[string]interface{}{ 157 | "response": { 158 | "duration": "183", 159 | "size": media.Size, 160 | "video_codec": format.VideoCodec, 161 | "rotation": strconv.Itoa(media.Rotation), 162 | }, 163 | } 164 | json.NewEncoder(w).Encode(resp) 165 | } 166 | 167 | func (s *encodingComFakeServer) getStatus(w io.Writer, req request) { 168 | media, err := s.getMedia(req.MediaID) 169 | if err != nil { 170 | s.Error(w, err.Error()) 171 | return 172 | } 173 | now := time.Now().UTC().Truncate(time.Second) 174 | status := "Saving" 175 | if media.Status == "Canceled" { 176 | status = media.Status 177 | } else if media.Status != "Finished" && now.Sub(media.Started) > time.Second { 178 | if media.Finished.IsZero() { 179 | media.Finished = now 180 | } 181 | status = "Finished" 182 | media.Status = status 183 | } else if media.Status != "" { 184 | status = media.Status 185 | } 186 | resp := map[string]map[string]interface{}{ 187 | "response": { 188 | "id": media.ID, 189 | "sourcefile": "http://some.source.file", 190 | "userid": "someuser", 191 | "status": status, 192 | "progress": "100.0", 193 | "time_left": "1", 194 | "created": media.Created.Format(encodingComDateFormat), 195 | "started": media.Started.Format(encodingComDateFormat), 196 | "finished": media.Finished.Format(encodingComDateFormat), 197 | "output": hlsOutput, 198 | "format": map[string]interface{}{ 199 | "destination": []string{ 200 | "https://mybucket.s3.amazonaws.com/dir/job-123/some_hls_preset/video-0.m3u8", 201 | "https://mybucket.s3.amazonaws.com/dir/job-123/video.m3u8", 202 | }, 203 | "destination_status": []string{"Saved", "Saved"}, 204 | "convertedsize": "45674", 205 | "size": media.Request.Format[0].Size, 206 | "bitrate": media.Request.Format[0].Bitrate, 207 | "output": media.Request.Format[0].Output[0], 208 | "video_codec": media.Request.Format[0].VideoCodec, 209 | "stream": []map[string]interface{}{ 210 | { 211 | "sub_path": "some_hls_preset", 212 | }, 213 | }, 214 | }, 215 | }, 216 | } 217 | json.NewEncoder(w).Encode(resp) 218 | } 219 | 220 | func (s *encodingComFakeServer) savePreset(w io.Writer, req request) { 221 | presetName := req.Name 222 | if presetName == "" { 223 | presetName = generateID() 224 | } 225 | s.presets[presetName] = &fakePreset{GivenName: req.Name, Request: req} 226 | resp := map[string]map[string]string{ 227 | "response": { 228 | "SavedPreset": presetName, 229 | }, 230 | } 231 | json.NewEncoder(w).Encode(resp) 232 | } 233 | 234 | func (s *encodingComFakeServer) getPreset(w io.Writer, req request) { 235 | preset, ok := s.presets[req.Name] 236 | if !ok { 237 | s.Error(w, req.Name+" preset not found") 238 | return 239 | } 240 | resp := map[string]*encodingcom.Preset{ 241 | "response": { 242 | Name: req.Name, 243 | Format: convertFormat(preset.Request.Format[0]), 244 | Output: preset.Request.Format[0].Output[0], 245 | Type: encodingcom.UserPresets, 246 | }, 247 | } 248 | json.NewEncoder(w).Encode(resp) 249 | } 250 | 251 | func (s *encodingComFakeServer) deletePreset(w io.Writer, req request) { 252 | if _, ok := s.presets[req.Name]; !ok { 253 | s.Error(w, "preset not found") 254 | return 255 | } 256 | delete(s.presets, req.Name) 257 | resp := map[string]*encodingcom.Response{"response": {Message: "Deleted"}} 258 | json.NewEncoder(w).Encode(resp) 259 | } 260 | 261 | func (s *encodingComFakeServer) Error(w io.Writer, message string) { 262 | m := map[string]errorResponse{"response": { 263 | Errors: errorList{Error: message}, 264 | }} 265 | json.NewEncoder(w).Encode(m) 266 | } 267 | 268 | func (s *encodingComFakeServer) getMedia(id string) (*fakeMedia, error) { 269 | media, ok := s.medias[id] 270 | if !ok { 271 | return nil, errMediaNotFound 272 | } 273 | return media, nil 274 | } 275 | 276 | func generateID() string { 277 | var id [8]byte 278 | rand.Read(id[:]) 279 | return fmt.Sprintf("%x", id[:]) 280 | } 281 | 282 | func convertFormat(format encodingcom.Format) encodingcom.PresetFormat { 283 | videoCodecParams, err := json.Marshal(format.VideoCodecParameters) 284 | if err != nil { 285 | log.Println(err.Error()) 286 | return encodingcom.PresetFormat{} 287 | } 288 | keyframe := "" 289 | if len(format.Keyframe) > 0 { 290 | keyframe = format.Keyframe[0] 291 | } 292 | return encodingcom.PresetFormat{ 293 | NoiseReduction: format.NoiseReduction, 294 | Output: format.Output[0], 295 | VideoCodec: format.VideoCodec, 296 | AudioCodec: format.AudioCodec, 297 | Bitrate: format.Bitrate, 298 | AudioBitrate: format.AudioBitrate, 299 | AudioSampleRate: format.AudioSampleRate, 300 | AudioChannelsNumber: format.AudioChannelsNumber, 301 | AudioVolume: format.AudioVolume, 302 | Size: format.Size, 303 | FadeIn: format.FadeIn, 304 | FadeOut: format.FadeOut, 305 | CropLeft: format.CropLeft, 306 | CropTop: format.CropTop, 307 | CropRight: format.CropRight, 308 | CropBottom: format.CropBottom, 309 | KeepAspectRatio: format.KeepAspectRatio, 310 | SetAspectRatio: format.SetAspectRatio, 311 | AddMeta: format.AddMeta, 312 | Hint: format.Hint, 313 | RcInitOccupancy: format.RcInitOccupancy, 314 | MinRate: format.MinRate, 315 | MaxRate: format.MaxRate, 316 | BufSize: format.BufSize, 317 | Keyframe: keyframe, 318 | Start: format.Start, 319 | Duration: format.Duration, 320 | ForceKeyframes: format.ForceKeyframes, 321 | Bframes: format.Bframes, 322 | Gop: format.Gop, 323 | Metadata: format.Metadata, 324 | SegmentDuration: strconv.FormatUint(uint64(format.SegmentDuration), 10), 325 | Logo: format.Logo, 326 | VideoCodecParameters: string(videoCodecParams), 327 | Profile: format.Profile, 328 | TwoPass: format.TwoPass, 329 | Turbo: format.Turbo, 330 | TwinTurbo: format.TwinTurbo, 331 | Rotate: format.Rotate, 332 | SetRotate: format.SetRotate, 333 | AudioSync: format.AudioSync, 334 | VideoSync: format.VideoSync, 335 | ForceInterlaced: format.ForceInterlaced, 336 | StripChapters: format.StripChapters, 337 | Framerate: format.Framerate, 338 | FramerateUpperThreshold: format.FramerateUpperThreshold, 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /internal/provider/fake_provider_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "github.com/video-dev/video-transcoding-api/v2/config" 5 | "github.com/video-dev/video-transcoding-api/v2/db" 6 | ) 7 | 8 | type fakeProvider struct { 9 | cap Capabilities 10 | healthErr error 11 | } 12 | 13 | func (*fakeProvider) Transcode(*db.Job) (*JobStatus, error) { 14 | return nil, nil 15 | } 16 | 17 | func (*fakeProvider) JobStatus(*db.Job) (*JobStatus, error) { 18 | return nil, nil 19 | } 20 | 21 | func (*fakeProvider) CreatePreset(db.Preset) (string, error) { 22 | return "", nil 23 | } 24 | 25 | func (*fakeProvider) GetPreset(string) (interface{}, error) { 26 | return "", nil 27 | } 28 | 29 | func (*fakeProvider) DeletePreset(string) error { 30 | return nil 31 | } 32 | 33 | func (*fakeProvider) CancelJob(string) error { 34 | return nil 35 | } 36 | 37 | func (f *fakeProvider) Healthcheck() error { 38 | return f.healthErr 39 | } 40 | 41 | func (f *fakeProvider) Capabilities() Capabilities { 42 | return f.cap 43 | } 44 | 45 | func getFactory(fErr error, healthErr error, capabilities Capabilities) Factory { 46 | return func(*config.Config) (TranscodingProvider, error) { 47 | if fErr != nil { 48 | return nil, fErr 49 | } 50 | return &fakeProvider{healthErr: healthErr, cap: capabilities}, nil 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/provider/mediaconvert/factory_test.go: -------------------------------------------------------------------------------- 1 | package mediaconvert 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/aws/aws-sdk-go-v2/service/mediaconvert" 8 | "github.com/video-dev/video-transcoding-api/v2/config" 9 | ) 10 | 11 | var cfgWithoutCredsAndRegion = config.Config{ 12 | MediaConvert: &config.MediaConvert{ 13 | Endpoint: "http://some/endpoint", 14 | Queue: "arn:some:queue", 15 | Role: "arn:some:role", 16 | }, 17 | } 18 | 19 | var cfgWithCredsAndRegion = config.Config{ 20 | MediaConvert: &config.MediaConvert{ 21 | AccessKeyID: "cfg_access_key_id", 22 | SecretAccessKey: "cfg_secret_access_key", 23 | Endpoint: "http://some/endpoint", 24 | Queue: "arn:some:queue", 25 | Role: "arn:some:role", 26 | Region: "us-cfg-region-1", 27 | }, 28 | } 29 | 30 | func Test_mediaconvertFactory(t *testing.T) { 31 | tests := []struct { 32 | name string 33 | envVars map[string]string 34 | cfg config.Config 35 | wantErrMsg string 36 | }{ 37 | { 38 | name: "when a config specifies aws credentials and region, those credentials are used", 39 | envVars: map[string]string{ 40 | "AWS_ACCESS_KEY_ID": "env_access_key_id", 41 | "AWS_SECRET_ACCESS_KEY": "env_secret_access_key", 42 | "AWS_DEFAULT_REGION": "us-north-1", 43 | }, 44 | cfg: cfgWithCredsAndRegion, 45 | }, 46 | { 47 | name: "when a config does not specify aws credentials or region, credentials and region are loaded " + 48 | "from the environment", 49 | envVars: map[string]string{ 50 | "AWS_ACCESS_KEY_ID": "env_access_key_id", 51 | "AWS_SECRET_ACCESS_KEY": "env_secret_access_key", 52 | "AWS_DEFAULT_REGION": "us-north-1", 53 | }, 54 | cfg: cfgWithoutCredsAndRegion, 55 | }, 56 | { 57 | name: "an incomplete cfg results in an error returned", 58 | cfg: config.Config{MediaConvert: &config.MediaConvert{}}, 59 | wantErrMsg: "incomplete MediaConvert config", 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | tt := tt 65 | t.Run(tt.name, func(t *testing.T) { 66 | for k, v := range tt.envVars { 67 | resetFunc, err := setenvReset(k, v) 68 | if err != nil { 69 | t.Errorf("running os env reset: %v", err) 70 | } 71 | defer resetFunc() 72 | } 73 | 74 | provider, err := mediaconvertFactory(&tt.cfg) 75 | if err != nil { 76 | if tt.wantErrMsg != err.Error() { 77 | t.Errorf("mcProvider.CreatePreset() error = %v, wantErr %q", err, tt.wantErrMsg) 78 | } 79 | return 80 | } 81 | 82 | p, ok := provider.(*mcProvider) 83 | if !ok { 84 | t.Error("factory didn't return a mediaconvert provider") 85 | return 86 | } 87 | 88 | _, ok = p.client.(*mediaconvert.Client) 89 | if !ok { 90 | t.Error("factory returned a mediaconvert provider with a non-aws client implementation") 91 | return 92 | } 93 | }) 94 | } 95 | } 96 | 97 | func setenvReset(name, val string) (resetEnv func(), rerr error) { 98 | cached := os.Getenv(name) 99 | err := os.Setenv(name, val) 100 | if err != nil { 101 | return nil, err 102 | } 103 | return func() { 104 | os.Setenv(name, cached) 105 | }, nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/provider/mediaconvert/fake_client_test.go: -------------------------------------------------------------------------------- 1 | package mediaconvert 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "testing" 7 | "unsafe" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/aws/aws-sdk-go-v2/service/mediaconvert" 11 | "github.com/aws/aws-sdk-go-v2/service/mediaconvert/types" 12 | ) 13 | 14 | // testMediaConvertClient is an implementation of the mediaconvertClient interface 15 | // to be used with tests 16 | type testMediaConvertClient struct { 17 | t *testing.T 18 | 19 | createPresetCalledWith *mediaconvert.CreatePresetInput 20 | getPresetCalledWith *string 21 | deletePresetCalledWith string 22 | createJobCalledWith mediaconvert.CreateJobInput 23 | cancelJobCalledWith string 24 | listJobsCalled bool 25 | 26 | jobReturnedByGetJob types.Job 27 | jobIDReturnedByCreateJob string 28 | getPresetContainerType types.ContainerType 29 | } 30 | 31 | func (c *testMediaConvertClient) CreatePreset(_ context.Context, input *mediaconvert.CreatePresetInput, _ ...func(*mediaconvert.Options)) (*mediaconvert.CreatePresetOutput, error) { 32 | c.createPresetCalledWith = input 33 | return &mediaconvert.CreatePresetOutput{ 34 | Preset: &types.Preset{ 35 | Name: input.Name, 36 | Settings: &types.PresetSettings{ 37 | ContainerSettings: &types.ContainerSettings{ 38 | Container: input.Settings.ContainerSettings.Container, 39 | }, 40 | }, 41 | }, 42 | }, nil 43 | } 44 | 45 | func (c *testMediaConvertClient) GetJob(context.Context, *mediaconvert.GetJobInput, ...func(*mediaconvert.Options)) (*mediaconvert.GetJobOutput, error) { 46 | return &mediaconvert.GetJobOutput{ 47 | Job: &c.jobReturnedByGetJob, 48 | }, nil 49 | } 50 | 51 | func (c *testMediaConvertClient) ListJobs(context.Context, *mediaconvert.ListJobsInput, ...func(*mediaconvert.Options)) (*mediaconvert.ListJobsOutput, error) { 52 | c.listJobsCalled = true 53 | return &mediaconvert.ListJobsOutput{}, nil 54 | } 55 | 56 | func (c *testMediaConvertClient) CreateJob(_ context.Context, input *mediaconvert.CreateJobInput, _ ...func(*mediaconvert.Options)) (*mediaconvert.CreateJobOutput, error) { 57 | c.createJobCalledWith = *input 58 | return &mediaconvert.CreateJobOutput{ 59 | Job: &types.Job{ 60 | Id: aws.String(c.jobIDReturnedByCreateJob), 61 | }, 62 | }, nil 63 | } 64 | 65 | func (c *testMediaConvertClient) CancelJob(_ context.Context, input *mediaconvert.CancelJobInput, _ ...func(*mediaconvert.Options)) (*mediaconvert.CancelJobOutput, error) { 66 | c.cancelJobCalledWith = *input.Id 67 | return &mediaconvert.CancelJobOutput{}, nil 68 | } 69 | 70 | func (c *testMediaConvertClient) GetPreset(_ context.Context, input *mediaconvert.GetPresetInput, _ ...func(*mediaconvert.Options)) (*mediaconvert.GetPresetOutput, error) { 71 | // atomically set the value of getPresetCalledWith to avoid data races, 72 | // should probably take a different approach? 73 | atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&c.getPresetCalledWith)), unsafe.Pointer(input.Name)) 74 | 75 | return &mediaconvert.GetPresetOutput{ 76 | Preset: &types.Preset{ 77 | Name: input.Name, 78 | Settings: &types.PresetSettings{ 79 | ContainerSettings: &types.ContainerSettings{ 80 | Container: c.getPresetContainerType, 81 | }, 82 | }, 83 | }, 84 | }, nil 85 | } 86 | 87 | func (c *testMediaConvertClient) DeletePreset(_ context.Context, input *mediaconvert.DeletePresetInput, _ ...func(*mediaconvert.Options)) (*mediaconvert.DeletePresetOutput, error) { 88 | c.deletePresetCalledWith = *input.Name 89 | return &mediaconvert.DeletePresetOutput{}, nil 90 | } 91 | -------------------------------------------------------------------------------- /internal/provider/mediaconvert/preset_mapping.go: -------------------------------------------------------------------------------- 1 | package mediaconvert 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/aws/aws-sdk-go-v2/service/mediaconvert/types" 9 | "github.com/pkg/errors" 10 | "github.com/video-dev/video-transcoding-api/v2/db" 11 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 12 | ) 13 | 14 | func providerStatusFrom(status types.JobStatus) provider.Status { 15 | switch status { 16 | case types.JobStatusSubmitted: 17 | return provider.StatusQueued 18 | case types.JobStatusProgressing: 19 | return provider.StatusStarted 20 | case types.JobStatusComplete: 21 | return provider.StatusFinished 22 | case types.JobStatusCanceled: 23 | return provider.StatusCanceled 24 | case types.JobStatusError: 25 | return provider.StatusFailed 26 | default: 27 | return provider.StatusUnknown 28 | } 29 | } 30 | 31 | func containerFrom(container string) (types.ContainerType, error) { 32 | container = strings.ToLower(container) 33 | switch container { 34 | case "m3u8": 35 | return types.ContainerTypeM3u8, nil 36 | case "mp4": 37 | return types.ContainerTypeMp4, nil 38 | default: 39 | return "", fmt.Errorf("container %q not supported with mediaconvert", container) 40 | } 41 | } 42 | 43 | func h264RateControlModeFrom(rateControl string) (types.H264RateControlMode, error) { 44 | rateControl = strings.ToLower(rateControl) 45 | switch rateControl { 46 | case "vbr": 47 | return types.H264RateControlModeVbr, nil 48 | case "", "cbr": 49 | return types.H264RateControlModeCbr, nil 50 | case "qvbr": 51 | return types.H264RateControlModeQvbr, nil 52 | default: 53 | return "", fmt.Errorf("rate control mode %q is not supported with mediaconvert", rateControl) 54 | } 55 | } 56 | 57 | func h264CodecProfileFrom(profile string) (types.H264CodecProfile, error) { 58 | profile = strings.ToLower(profile) 59 | switch profile { 60 | case "baseline": 61 | return types.H264CodecProfileBaseline, nil 62 | case "main": 63 | return types.H264CodecProfileMain, nil 64 | case "", "high": 65 | return types.H264CodecProfileHigh, nil 66 | default: 67 | return "", fmt.Errorf("h264 profile %q is not supported with mediaconvert", profile) 68 | } 69 | } 70 | 71 | func h264CodecLevelFrom(level string) (types.H264CodecLevel, error) { 72 | switch level { 73 | case "": 74 | return types.H264CodecLevelAuto, nil 75 | case "1", "1.0": 76 | return types.H264CodecLevelLevel1, nil 77 | case "1.1": 78 | return types.H264CodecLevelLevel11, nil 79 | case "1.2": 80 | return types.H264CodecLevelLevel12, nil 81 | case "1.3": 82 | return types.H264CodecLevelLevel13, nil 83 | case "2", "2.0": 84 | return types.H264CodecLevelLevel2, nil 85 | case "2.1": 86 | return types.H264CodecLevelLevel21, nil 87 | case "2.2": 88 | return types.H264CodecLevelLevel22, nil 89 | case "3", "3.0": 90 | return types.H264CodecLevelLevel3, nil 91 | case "3.1": 92 | return types.H264CodecLevelLevel31, nil 93 | case "3.2": 94 | return types.H264CodecLevelLevel32, nil 95 | case "4", "4.0": 96 | return types.H264CodecLevelLevel4, nil 97 | case "4.1": 98 | return types.H264CodecLevelLevel41, nil 99 | case "4.2": 100 | return types.H264CodecLevelLevel42, nil 101 | case "5", "5.0": 102 | return types.H264CodecLevelLevel5, nil 103 | case "5.1": 104 | return types.H264CodecLevelLevel51, nil 105 | case "5.2": 106 | return types.H264CodecLevelLevel52, nil 107 | default: 108 | return "", fmt.Errorf("h264 level %q is not supported with mediaconvert", level) 109 | } 110 | } 111 | 112 | func h264InterlaceModeFrom(mode string) (types.H264InterlaceMode, error) { 113 | mode = strings.ToLower(mode) 114 | switch mode { 115 | case "", "progressive": 116 | return types.H264InterlaceModeProgressive, nil 117 | default: 118 | return "", fmt.Errorf("h264 interlace mode %q is not supported with mediaconvert", mode) 119 | } 120 | } 121 | 122 | func videoPresetFrom(preset db.Preset) (*types.VideoDescription, error) { 123 | videoPreset := types.VideoDescription{ 124 | ScalingBehavior: types.ScalingBehaviorDefault, 125 | TimecodeInsertion: types.VideoTimecodeInsertionDisabled, 126 | AntiAlias: types.AntiAliasEnabled, 127 | RespondToAfd: types.RespondToAfdNone, 128 | } 129 | 130 | if preset.Video.Width != "" { 131 | width, err := strconv.ParseInt(preset.Video.Width, 10, 32) 132 | if err != nil { 133 | return nil, errors.Wrapf(err, "parsing video width %q to int32", preset.Video.Width) 134 | } 135 | videoPreset.Width = int32(width) 136 | } 137 | 138 | if preset.Video.Height != "" { 139 | height, err := strconv.ParseInt(preset.Video.Height, 10, 32) 140 | if err != nil { 141 | return nil, errors.Wrapf(err, "parsing video height %q to int32", preset.Video.Height) 142 | } 143 | videoPreset.Height = int32(height) 144 | } 145 | 146 | codec := strings.ToLower(preset.Video.Codec) 147 | switch codec { 148 | case "h264": 149 | bitrate, err := strconv.ParseInt(preset.Video.Bitrate, 10, 32) 150 | if err != nil { 151 | return nil, errors.Wrapf(err, "parsing video bitrate %q to int32", preset.Video.Bitrate) 152 | } 153 | 154 | gopSize, err := strconv.ParseFloat(preset.Video.GopSize, 64) 155 | if err != nil { 156 | return nil, errors.Wrapf(err, "parsing gop size %q to float64", preset.Video.GopSize) 157 | } 158 | 159 | rateControl, err := h264RateControlModeFrom(preset.RateControl) 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | profile, err := h264CodecProfileFrom(preset.Video.Profile) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | level, err := h264CodecLevelFrom(preset.Video.ProfileLevel) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | interlaceMode, err := h264InterlaceModeFrom(preset.Video.InterlaceMode) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | tuning := types.H264QualityTuningLevelSinglePassHq 180 | if preset.TwoPass { 181 | tuning = types.H264QualityTuningLevelMultiPassHq 182 | } 183 | 184 | var bframes int64 185 | if preset.Video.BFrames != "" { 186 | bframes, err = strconv.ParseInt(preset.Video.BFrames, 10, 32) 187 | if err != nil { 188 | return nil, errors.Wrapf(err, "parsing bframes %q to int32", preset.Video.BFrames) 189 | } 190 | } 191 | 192 | videoPreset.CodecSettings = &types.VideoCodecSettings{ 193 | Codec: types.VideoCodecH264, 194 | H264Settings: &types.H264Settings{ 195 | Bitrate: int32(bitrate), 196 | GopSize: gopSize, 197 | RateControlMode: rateControl, 198 | CodecProfile: profile, 199 | CodecLevel: level, 200 | InterlaceMode: interlaceMode, 201 | QualityTuningLevel: tuning, 202 | NumberBFramesBetweenReferenceFrames: int32(bframes), 203 | }, 204 | } 205 | default: 206 | return nil, fmt.Errorf("video codec %q is not yet supported with mediaconvert", codec) 207 | } 208 | 209 | return &videoPreset, nil 210 | } 211 | 212 | func audioPresetFrom(preset db.Preset) (*types.AudioDescription, error) { 213 | audioPreset := types.AudioDescription{} 214 | 215 | codec := strings.ToLower(preset.Audio.Codec) 216 | switch codec { 217 | case "aac": 218 | bitrate, err := strconv.ParseInt(preset.Audio.Bitrate, 10, 32) 219 | if err != nil { 220 | return nil, errors.Wrapf(err, "parsing audio bitrate %q to int32", preset.Audio.Bitrate) 221 | } 222 | 223 | audioPreset.CodecSettings = &types.AudioCodecSettings{ 224 | Codec: types.AudioCodecAac, 225 | AacSettings: &types.AacSettings{ 226 | SampleRate: defaultAudioSampleRate, 227 | Bitrate: int32(bitrate), 228 | CodecProfile: types.AacCodecProfileLc, 229 | CodingMode: types.AacCodingModeCodingMode20, 230 | RateControlMode: types.AacRateControlModeCbr, 231 | }, 232 | } 233 | default: 234 | return nil, fmt.Errorf("audio codec %q is not yet supported with mediaconvert", codec) 235 | } 236 | 237 | return &audioPreset, nil 238 | } 239 | -------------------------------------------------------------------------------- /internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sort" 7 | "time" 8 | 9 | "github.com/video-dev/video-transcoding-api/v2/config" 10 | "github.com/video-dev/video-transcoding-api/v2/db" 11 | ) 12 | 13 | var ( 14 | // ErrProviderAlreadyRegistered is the error returned when trying to register a 15 | // provider twice. 16 | ErrProviderAlreadyRegistered = errors.New("provider is already registered") 17 | 18 | // ErrProviderNotFound is the error returned when asking for a provider 19 | // that is not registered. 20 | ErrProviderNotFound = errors.New("provider not found") 21 | 22 | // ErrPresetMapNotFound is the error returned when the given preset is not 23 | // found in the provider. 24 | ErrPresetMapNotFound = errors.New("preset not found in provider") 25 | ) 26 | 27 | // TranscodingProvider represents a provider of transcoding. 28 | // 29 | // It defines a basic API for transcoding a media and query the status of a 30 | // Job. The underlying provider should handle the profileSpec as desired (it 31 | // might be a JSON, or an XML, or anything else. 32 | type TranscodingProvider interface { 33 | Transcode(*db.Job) (*JobStatus, error) 34 | JobStatus(*db.Job) (*JobStatus, error) 35 | CancelJob(id string) error 36 | CreatePreset(db.Preset) (string, error) 37 | DeletePreset(presetID string) error 38 | GetPreset(presetID string) (interface{}, error) 39 | 40 | // Healthcheck should return nil if the provider is currently available 41 | // for transcoding videos, otherwise it should return an error 42 | // explaining what's going on. 43 | Healthcheck() error 44 | 45 | // Capabilities describes the capabilities of the provider. 46 | Capabilities() Capabilities 47 | } 48 | 49 | // Factory is the function responsible for creating the instance of a 50 | // provider. 51 | type Factory func(cfg *config.Config) (TranscodingProvider, error) 52 | 53 | // InvalidConfigError is returned if a provider could not be configured properly 54 | type InvalidConfigError string 55 | 56 | // JobNotFoundError is returned if a job with a given id could not be found by the provider 57 | type JobNotFoundError struct { 58 | ID string 59 | } 60 | 61 | func (err InvalidConfigError) Error() string { 62 | return string(err) 63 | } 64 | 65 | func (err JobNotFoundError) Error() string { 66 | return fmt.Sprintf("could not found job with id: %s", err.ID) 67 | } 68 | 69 | // JobStatus is the representation of the status as the provide sees it. The 70 | // provider is able to add customized information in the ProviderStatus field. 71 | // 72 | // swagger:model 73 | type JobStatus struct { 74 | ProviderJobID string `json:"providerJobId,omitempty"` 75 | Status Status `json:"status,omitempty"` 76 | ProviderName string `json:"providerName,omitempty"` 77 | StatusMessage string `json:"statusMessage,omitempty"` 78 | Progress float64 `json:"progress"` 79 | ProviderStatus map[string]interface{} `json:"providerStatus,omitempty"` 80 | Output JobOutput `json:"output"` 81 | SourceInfo SourceInfo `json:"sourceInfo,omitempty"` 82 | } 83 | 84 | // JobOutput represents information about a job output. 85 | type JobOutput struct { 86 | Destination string `json:"destination,omitempty"` 87 | Files []OutputFile `json:"files,omitempty"` 88 | } 89 | 90 | // OutputFile represents an output file in a given job. 91 | type OutputFile struct { 92 | Path string `json:"path"` 93 | Container string `json:"container"` 94 | VideoCodec string `json:"videoCodec"` 95 | Height int64 `json:"height"` 96 | Width int64 `json:"width"` 97 | FileSize int64 `json:"fileSize"` 98 | } 99 | 100 | // SourceInfo contains information about media transcoded using the Transcoding 101 | // API. 102 | type SourceInfo struct { 103 | // Duration of the media 104 | Duration time.Duration `json:"duration,omitempty"` 105 | 106 | // Dimension of the media, in pixels 107 | Height int64 `json:"height,omitempty"` 108 | Width int64 `json:"width,omitempty"` 109 | 110 | // Codec used for video medias 111 | VideoCodec string `json:"videoCodec,omitempty"` 112 | } 113 | 114 | // Status is the status of a transcoding job. 115 | type Status string 116 | 117 | const ( 118 | // StatusQueued is the status for a job that is in the queue for 119 | // execution. 120 | StatusQueued = Status("queued") 121 | 122 | // StatusStarted is the status for a job that is being executed. 123 | StatusStarted = Status("started") 124 | 125 | // StatusFinished is the status for a job that finished successfully. 126 | StatusFinished = Status("finished") 127 | 128 | // StatusFailed is the status for a job that has failed. 129 | StatusFailed = Status("failed") 130 | 131 | // StatusCanceled is the status for a job that has been canceled. 132 | StatusCanceled = Status("canceled") 133 | 134 | // StatusUnknown is an unexpected status for a job. 135 | StatusUnknown = Status("unknown") 136 | ) 137 | 138 | var providers map[string]Factory 139 | 140 | // Register register a new provider in the internal list of providers. 141 | func Register(name string, provider Factory) error { 142 | if providers == nil { 143 | providers = make(map[string]Factory) 144 | } 145 | if _, ok := providers[name]; ok { 146 | return ErrProviderAlreadyRegistered 147 | } 148 | providers[name] = provider 149 | return nil 150 | } 151 | 152 | // GetProviderFactory looks up the list of registered providers and returns the 153 | // factory function for the given provider name, if it's available. 154 | func GetProviderFactory(name string) (Factory, error) { 155 | factory, ok := providers[name] 156 | if !ok { 157 | return nil, ErrProviderNotFound 158 | } 159 | return factory, nil 160 | } 161 | 162 | // ListProviders returns the list of currently registered providers, 163 | // alphabetically ordered. 164 | func ListProviders(c *config.Config) []string { 165 | providerNames := make([]string, 0, len(providers)) 166 | for name, factory := range providers { 167 | if _, err := factory(c); err == nil { 168 | providerNames = append(providerNames, name) 169 | } 170 | } 171 | sort.Strings(providerNames) 172 | return providerNames 173 | } 174 | 175 | // DescribeProvider describes the given provider. It includes information about 176 | // the provider's capabilities and its current health state. 177 | func DescribeProvider(name string, c *config.Config) (*Description, error) { 178 | factory, err := GetProviderFactory(name) 179 | if err != nil { 180 | return nil, err 181 | } 182 | description := Description{Name: name} 183 | provider, err := factory(c) 184 | if err != nil { 185 | return &description, nil 186 | } 187 | description.Enabled = true 188 | description.Capabilities = provider.Capabilities() 189 | description.Health = Health{OK: true} 190 | if err = provider.Healthcheck(); err != nil { 191 | description.Health = Health{OK: false, Message: err.Error()} 192 | } 193 | return &description, nil 194 | } 195 | -------------------------------------------------------------------------------- /internal/provider/provider_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/video-dev/video-transcoding-api/v2/config" 9 | ) 10 | 11 | func noopFactory(*config.Config) (TranscodingProvider, error) { 12 | return nil, nil 13 | } 14 | 15 | func TestRegister(t *testing.T) { 16 | providers = nil 17 | err := Register("noop", noopFactory) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | if _, ok := providers["noop"]; !ok { 22 | t.Errorf("expected to get the noop factory register. Got map %#v", providers) 23 | } 24 | } 25 | 26 | func TestRegisterMultiple(t *testing.T) { 27 | providers = nil 28 | err := Register("noop", noopFactory) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | err = Register("noope", noopFactory) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | if _, ok := providers["noop"]; !ok { 37 | t.Errorf("expected to get the noop factory register. Got map %#v", providers) 38 | } 39 | if _, ok := providers["noope"]; !ok { 40 | t.Errorf("expected to get the noope factory register. Got map %#v", providers) 41 | } 42 | } 43 | 44 | func TestRegisterDuplicate(t *testing.T) { 45 | providers = nil 46 | err := Register("noop", noopFactory) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | err = Register("noop", noopFactory) 51 | if err != ErrProviderAlreadyRegistered { 52 | t.Errorf("Got wrong error when registering provider twice. Want %#v. Got %#v", ErrProviderAlreadyRegistered, err) 53 | } 54 | } 55 | 56 | func TestGetProviderFactory(t *testing.T) { 57 | providers = nil 58 | var called bool 59 | err := Register("noop", func(*config.Config) (TranscodingProvider, error) { 60 | called = true 61 | return nil, nil 62 | }) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | factory, err := GetProviderFactory("noop") 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | factory(nil) 71 | if !called { 72 | t.Errorf("Did not call the expected factory. Got %#v", factory) 73 | } 74 | } 75 | 76 | func TestGetProviderFactoryNotRegistered(t *testing.T) { 77 | providers = nil 78 | factory, err := GetProviderFactory("noop") 79 | if factory != nil { 80 | t.Errorf("Got unexpected non-nil factory: %#v", factory) 81 | } 82 | if err != ErrProviderNotFound { 83 | t.Errorf("Got wrong error when getting an unregistered provider. Want %#v. Got %#v", ErrProviderNotFound, err) 84 | } 85 | } 86 | 87 | func TestListProviders(t *testing.T) { 88 | cap := Capabilities{ 89 | InputFormats: []string{"prores", "h264"}, 90 | OutputFormats: []string{"mp4", "hls"}, 91 | Destinations: []string{"s3", "akamai"}, 92 | } 93 | providers = map[string]Factory{ 94 | "cap-and-unhealthy": getFactory(nil, errors.New("api is down"), cap), 95 | "factory-err": getFactory(errors.New("invalid config"), nil, cap), 96 | "cap-and-healthy": getFactory(nil, nil, cap), 97 | } 98 | expected := []string{"cap-and-healthy", "cap-and-unhealthy"} 99 | got := ListProviders(&config.Config{}) 100 | if !reflect.DeepEqual(got, expected) { 101 | t.Errorf("DescribeProviders: want %#v. Got %#v", expected, got) 102 | } 103 | } 104 | 105 | func TestListProvidersEmpty(t *testing.T) { 106 | providers = nil 107 | providerNames := ListProviders(&config.Config{}) 108 | if len(providerNames) != 0 { 109 | t.Errorf("Unexpected non-empty provider list: %#v", providerNames) 110 | } 111 | } 112 | 113 | func TestDescribeProvider(t *testing.T) { 114 | cap := Capabilities{ 115 | InputFormats: []string{"prores", "h264"}, 116 | OutputFormats: []string{"mp4", "hls"}, 117 | Destinations: []string{"s3", "akamai"}, 118 | } 119 | providers = map[string]Factory{ 120 | "cap-and-unhealthy": getFactory(nil, errors.New("api is down"), cap), 121 | "factory-err": getFactory(errors.New("invalid config"), nil, cap), 122 | "cap-and-healthy": getFactory(nil, nil, cap), 123 | } 124 | tests := []struct { 125 | input string 126 | expected Description 127 | }{ 128 | { 129 | "factory-err", 130 | Description{Name: "factory-err", Enabled: false}, 131 | }, 132 | { 133 | "cap-and-healthy", 134 | Description{ 135 | Name: "cap-and-healthy", 136 | Capabilities: cap, 137 | Health: Health{OK: true}, 138 | Enabled: true, 139 | }, 140 | }, 141 | { 142 | "cap-and-unhealthy", 143 | Description{ 144 | Name: "cap-and-unhealthy", 145 | Capabilities: cap, 146 | Health: Health{OK: false, Message: "api is down"}, 147 | Enabled: true, 148 | }, 149 | }, 150 | } 151 | for _, test := range tests { 152 | description, err := DescribeProvider(test.input, &config.Config{}) 153 | if err != nil { 154 | t.Error(err) 155 | } 156 | if !reflect.DeepEqual(*description, test.expected) { 157 | t.Errorf("DescribeProvider(%q): want %#v. Got %#v", test.input, test.expected, *description) 158 | } 159 | } 160 | } 161 | 162 | func TestDescribeProviderNotFound(t *testing.T) { 163 | providers = nil 164 | description, err := DescribeProvider("anything", nil) 165 | if err != ErrProviderNotFound { 166 | t.Errorf("Wrong error. Want %#v. Got %#v", ErrProviderNotFound, err) 167 | } 168 | if description != nil { 169 | t.Errorf("Unexpected non-nil description: %#v", description) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /internal/provider/zencoder/zencoder_fake_test.go: -------------------------------------------------------------------------------- 1 | package zencoder 2 | 3 | import "github.com/video-dev/zencoder" 4 | 5 | type FakeZencoder struct { 6 | } 7 | 8 | func (z *FakeZencoder) CreateJob(settings *zencoder.EncodingSettings) (*zencoder.CreateJobResponse, error) { 9 | return &zencoder.CreateJobResponse{ 10 | Id: 123, 11 | }, nil 12 | } 13 | 14 | func (z *FakeZencoder) CancelJob(id int64) error { 15 | return nil 16 | } 17 | 18 | func (z *FakeZencoder) GetJobProgress(id int64) (*zencoder.JobProgress, error) { 19 | if id == 1234567890 { 20 | return &zencoder.JobProgress{State: "processing", JobProgress: 10}, nil 21 | } 22 | return &zencoder.JobProgress{State: "finished", JobProgress: 0}, nil 23 | } 24 | 25 | func (z *FakeZencoder) GetJobDetails(id int64) (*zencoder.JobDetails, error) { 26 | state := "finished" 27 | if id == 1234567890 || id == 837958345 { 28 | state = "processing" 29 | } 30 | return &zencoder.JobDetails{ 31 | Job: &zencoder.Job{ 32 | State: state, 33 | InputMediaFile: &zencoder.MediaFile{ 34 | Url: "http://nyt.net/input.mov", 35 | Format: "mov", 36 | VideoCodec: "ProRes422", 37 | Width: 1920, 38 | Height: 1080, 39 | DurationInMs: 50000, 40 | }, 41 | CreatedAt: "2016-11-05T05:02:57Z", 42 | FinishedAt: "2016-11-05T05:02:57Z", 43 | UpdatedAt: "2016-11-05T05:02:57Z", 44 | SubmittedAt: "2016-11-05T05:02:57Z", 45 | OutputMediaFiles: []*zencoder.MediaFile{ 46 | { 47 | Url: "https://mybucket.s3.amazonaws.com/destination-dir/output1.mp4", 48 | Format: "mp4", 49 | VideoCodec: "h264", 50 | Width: 1920, 51 | Height: 1080, 52 | DurationInMs: 10000, 53 | FileSizeInBytes: 66885256, 54 | }, 55 | { 56 | Url: "https://mybucket.s3.amazonaws.com/destination-dir/output2.webm", 57 | Format: "webm", 58 | VideoCodec: "vp8", 59 | Width: 1080, 60 | Height: 720, 61 | DurationInMs: 10000, 62 | FileSizeInBytes: 92140022, 63 | }, 64 | }, 65 | }, 66 | }, nil 67 | } 68 | 69 | func (z *FakeZencoder) GetVodUsage(settings *zencoder.ReportSettings) (*zencoder.VodUsage, error) { 70 | return &zencoder.VodUsage{}, nil 71 | } 72 | -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/video-dev/video-transcoding-api/2c78f9bffee7b113bb5ad2b5c998895479563d07/logo/logo.png -------------------------------------------------------------------------------- /logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | 7 | "github.com/NYTimes/gizmo/server" 8 | "github.com/google/gops/agent" 9 | "github.com/video-dev/video-transcoding-api/v2/config" 10 | _ "github.com/video-dev/video-transcoding-api/v2/internal/provider/bitmovin" 11 | _ "github.com/video-dev/video-transcoding-api/v2/internal/provider/elementalconductor" 12 | _ "github.com/video-dev/video-transcoding-api/v2/internal/provider/encodingcom" 13 | _ "github.com/video-dev/video-transcoding-api/v2/internal/provider/hybrik" 14 | _ "github.com/video-dev/video-transcoding-api/v2/internal/provider/mediaconvert" 15 | _ "github.com/video-dev/video-transcoding-api/v2/internal/provider/zencoder" 16 | "github.com/video-dev/video-transcoding-api/v2/service" 17 | ) 18 | 19 | func main() { 20 | agent.Listen(agent.Options{}) 21 | defer agent.Close() 22 | cfg := config.LoadConfig() 23 | server.Init("video-transcoding-api", cfg.Server) 24 | server.Log.Out = ioutil.Discard 25 | 26 | logger, err := cfg.Log.Logger() 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | service, err := service.NewTranscodingService(cfg, logger) 32 | if err != nil { 33 | logger.Fatal("unable to initialize service: ", err) 34 | } 35 | err = server.Register(service) 36 | if err != nil { 37 | logger.Fatal("unable to register service: ", err) 38 | } 39 | err = server.Run() 40 | if err != nil { 41 | logger.Fatal("server encountered a fatal error: ", err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /service/fake_provider_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/video-dev/video-transcoding-api/v2/config" 5 | "github.com/video-dev/video-transcoding-api/v2/db" 6 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 7 | ) 8 | 9 | func init() { 10 | provider.Register("fake", fakeProviderFactory) 11 | provider.Register("zencoder", fakeProviderFactory) 12 | } 13 | 14 | type fakeProvider struct { 15 | jobs []*db.Job 16 | canceledJobs []string 17 | } 18 | 19 | var fprovider fakeProvider 20 | 21 | func (p *fakeProvider) Transcode(job *db.Job) (*provider.JobStatus, error) { 22 | for _, output := range job.Outputs { 23 | if _, ok := output.Preset.ProviderMapping["fake"]; !ok { 24 | return nil, provider.ErrPresetMapNotFound 25 | } 26 | } 27 | p.jobs = append(p.jobs, job) 28 | return &provider.JobStatus{ 29 | ProviderJobID: "provider-preset-job-123", 30 | Status: provider.StatusFinished, 31 | StatusMessage: "The job is finished", 32 | ProviderStatus: map[string]interface{}{ 33 | "progress": 100.0, 34 | "sourcefile": "http://some.source.file", 35 | }, 36 | }, nil 37 | } 38 | 39 | func (*fakeProvider) CreatePreset(preset db.Preset) (string, error) { 40 | return "presetID_here", nil 41 | } 42 | 43 | func (*fakeProvider) GetPreset(presetID string) (interface{}, error) { 44 | return struct{ presetID string }{"presetID_here"}, nil 45 | } 46 | 47 | func (*fakeProvider) DeletePreset(presetID string) error { 48 | return nil 49 | } 50 | 51 | func (p *fakeProvider) JobStatus(job *db.Job) (*provider.JobStatus, error) { 52 | id := job.ProviderJobID 53 | if id == "provider-job-123" { 54 | status := provider.StatusFinished 55 | if len(p.canceledJobs) > 0 { 56 | status = provider.StatusCanceled 57 | } 58 | return &provider.JobStatus{ 59 | ProviderJobID: "provider-job-123", 60 | Status: status, 61 | StatusMessage: "The job is finished", 62 | Progress: 10.3, 63 | SourceInfo: provider.SourceInfo{ 64 | Width: 4096, 65 | Height: 2160, 66 | Duration: 183e9, 67 | VideoCodec: "VP9", 68 | }, 69 | ProviderStatus: map[string]interface{}{ 70 | "progress": 10.3, 71 | "sourcefile": "http://some.source.file", 72 | }, 73 | Output: provider.JobOutput{ 74 | Destination: "s3://mybucket/some/dir/job-123", 75 | }, 76 | }, nil 77 | } 78 | return nil, provider.JobNotFoundError{ID: id} 79 | } 80 | 81 | func (p *fakeProvider) CancelJob(id string) error { 82 | if id == "provider-job-123" { 83 | p.canceledJobs = append(p.canceledJobs, id) 84 | return nil 85 | } 86 | return provider.JobNotFoundError{ID: id} 87 | } 88 | 89 | func (p *fakeProvider) Healthcheck() error { 90 | return nil 91 | } 92 | 93 | func (p *fakeProvider) Capabilities() provider.Capabilities { 94 | return provider.Capabilities{ 95 | InputFormats: []string{"prores", "h264"}, 96 | OutputFormats: []string{"mp4", "webm", "hls"}, 97 | Destinations: []string{"akamai", "s3"}, 98 | } 99 | } 100 | 101 | func fakeProviderFactory(_ *config.Config) (provider.TranscodingProvider, error) { 102 | return &fprovider, nil 103 | } 104 | -------------------------------------------------------------------------------- /service/preset.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | 9 | "github.com/NYTimes/gizmo/server" 10 | "github.com/video-dev/video-transcoding-api/v2/db" 11 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 12 | "github.com/video-dev/video-transcoding-api/v2/swagger" 13 | ) 14 | 15 | // swagger:route DELETE /presets/{name} presets deletePreset 16 | // 17 | // Deletes a preset by name. 18 | // 19 | // Responses: 20 | // 200: deletePresetOutputs 21 | // 404: presetNotFound 22 | // 500: genericError 23 | func (s *TranscodingService) deletePreset(r *http.Request) swagger.GizmoJSONResponse { 24 | var output deletePresetOutputs 25 | var params getPresetMapInput 26 | params.loadParams(server.Vars(r)) 27 | 28 | output.Results = make(map[string]deletePresetOutput) 29 | 30 | presetmap, err := s.db.GetPresetMap(params.Name) 31 | if err != nil { 32 | output.PresetMap = "couldn't retrieve: " + err.Error() 33 | } else { 34 | for p, presetID := range presetmap.ProviderMapping { 35 | providerFactory, ierr := provider.GetProviderFactory(p) 36 | if ierr != nil { 37 | output.Results[p] = deletePresetOutput{PresetID: "", Error: "getting factory: " + ierr.Error()} 38 | continue 39 | } 40 | providerObj, ierr := providerFactory(s.config) 41 | if ierr != nil { 42 | output.Results[p] = deletePresetOutput{PresetID: "", Error: "initializing provider: " + ierr.Error()} 43 | continue 44 | } 45 | ierr = providerObj.DeletePreset(presetID) 46 | if ierr != nil { 47 | output.Results[p] = deletePresetOutput{PresetID: "", Error: "deleting preset: " + ierr.Error()} 48 | continue 49 | } 50 | output.Results[p] = deletePresetOutput{PresetID: presetID, Error: ""} 51 | } 52 | err = s.db.DeletePresetMap(&db.PresetMap{Name: params.Name}) 53 | if err != nil { 54 | output.PresetMap = "error: " + err.Error() 55 | } else { 56 | output.PresetMap = "removed successfully" 57 | } 58 | } 59 | return &deletePresetResponse{ 60 | baseResponse: baseResponse{ 61 | payload: output, 62 | status: http.StatusOK, 63 | }, 64 | } 65 | } 66 | 67 | // swagger:route POST /presets presets Output 68 | // 69 | // Creates a new preset on given providers. 70 | // Responses: 71 | // 200: newPresetOutputs 72 | // 400: invalidPreset 73 | // 500: genericError 74 | func (s *TranscodingService) newPreset(r *http.Request) swagger.GizmoJSONResponse { 75 | defer r.Body.Close() 76 | var input newPresetInput 77 | var output newPresetOutputs 78 | var presetMap *db.PresetMap 79 | var providers []string 80 | var shouldCreatePresetMap bool 81 | 82 | respData, err := ioutil.ReadAll(r.Body) 83 | if err != nil { 84 | return swagger.NewErrorResponse(err) 85 | } 86 | 87 | err = json.Unmarshal(respData, &input) 88 | if err != nil { 89 | return swagger.NewErrorResponse(err) 90 | } 91 | 92 | output.Results = make(map[string]newPresetOutput) 93 | 94 | // Sometimes we try to create a new preset in a new provider but we already 95 | // have the PresetMap stored. We want to update the PresetMap in such cases. 96 | presetMap, err = s.db.GetPresetMap(input.Preset.Name) 97 | if err == db.ErrPresetMapNotFound { 98 | presetMap = &db.PresetMap{Name: input.Preset.Name} 99 | presetMap.OutputOpts = input.OutputOptions 100 | presetMap.OutputOpts.Extension = input.Preset.Container 101 | presetMap.ProviderMapping = make(map[string]string) 102 | if err = presetMap.OutputOpts.Validate(); err != nil { 103 | return newInvalidPresetResponse(fmt.Errorf("invalid outputOptions: %s", err)) 104 | } 105 | shouldCreatePresetMap = true 106 | providers = input.Providers 107 | } else if err != nil { 108 | return swagger.NewErrorResponse(err) 109 | } else { 110 | // If we already have a PresetMap for this preset, we just need to create the 111 | // preset on the providers that are not mapped yet. 112 | providers = s.getMissingProviders(input.Providers, presetMap.ProviderMapping) 113 | 114 | // We also want to add the existent presets on the result. 115 | for provider, presetID := range presetMap.ProviderMapping { 116 | output.Results[provider] = newPresetOutput{PresetID: presetID, Error: ""} 117 | } 118 | } 119 | 120 | for _, p := range providers { 121 | providerFactory, ierr := provider.GetProviderFactory(p) 122 | if ierr != nil { 123 | output.Results[p] = newPresetOutput{PresetID: "", Error: "getting factory: " + ierr.Error()} 124 | continue 125 | } 126 | providerObj, ierr := providerFactory(s.config) 127 | if ierr != nil { 128 | output.Results[p] = newPresetOutput{PresetID: "", Error: "initializing provider: " + ierr.Error()} 129 | continue 130 | } 131 | presetID, ierr := providerObj.CreatePreset(input.Preset) 132 | if ierr != nil { 133 | output.Results[p] = newPresetOutput{PresetID: "", Error: "creating preset: " + ierr.Error()} 134 | continue 135 | } 136 | presetMap.ProviderMapping[p] = presetID 137 | output.Results[p] = newPresetOutput{PresetID: presetID, Error: ""} 138 | } 139 | 140 | status := http.StatusOK 141 | if len(presetMap.ProviderMapping) > 0 { 142 | if shouldCreatePresetMap { 143 | err = s.db.CreatePresetMap(presetMap) 144 | } else { 145 | err = s.db.UpdatePresetMap(presetMap) 146 | } 147 | if err != nil { 148 | return newInvalidPresetResponse(fmt.Errorf("failed creating/updating presetmap after creating presets: %s", err)) 149 | } 150 | output.PresetMap = presetMap.Name 151 | } else { 152 | status = http.StatusInternalServerError 153 | } 154 | 155 | return &newPresetResponse{ 156 | baseResponse: baseResponse{ 157 | payload: output, 158 | status: status, 159 | }, 160 | } 161 | } 162 | 163 | // getMissingProviders will check what providers already have a preset associated to it 164 | // and return the missing ones. This method is used when a request to create a new preset 165 | // is done but we already have a PresetMap stored locally. 166 | func (s *TranscodingService) getMissingProviders(inputProviders []string, providerMapping map[string]string) []string { 167 | var missingProviders []string 168 | for _, provider := range inputProviders { 169 | if _, ok := providerMapping[provider]; !ok { 170 | missingProviders = append(missingProviders, provider) 171 | } 172 | } 173 | return missingProviders 174 | } 175 | -------------------------------------------------------------------------------- /service/preset_params.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/video-dev/video-transcoding-api/v2/db" 5 | ) 6 | 7 | type newPresetInput struct { 8 | Providers []string `json:"providers"` 9 | Preset db.Preset `json:"preset"` 10 | OutputOptions db.OutputOptions `json:"outputOptions"` 11 | } 12 | 13 | // list of the results of the attempt to create a preset 14 | // in each provider. 15 | // 16 | // swagger:response newPresetOutputs 17 | type newPresetOutputs struct { 18 | // in: body 19 | // required: true 20 | Results map[string]newPresetOutput 21 | PresetMap string 22 | } 23 | 24 | type newPresetOutput struct { 25 | PresetID string 26 | Error string 27 | } 28 | 29 | // list of the results of the attempt to delete a preset 30 | // in each provider. 31 | // 32 | // swagger:response deletePresetOutputs 33 | type deletePresetOutputs struct { 34 | // in: body 35 | // required: true 36 | Results map[string]deletePresetOutput `json:"results"` 37 | PresetMap string `json:"presetMap"` 38 | } 39 | 40 | type deletePresetOutput struct { 41 | PresetID string `json:"presetId"` 42 | Error string `json:"error,omitempty"` 43 | } 44 | -------------------------------------------------------------------------------- /service/preset_responses.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/video-dev/video-transcoding-api/v2/swagger" 7 | ) 8 | 9 | type newPresetResponse struct { 10 | baseResponse 11 | } 12 | 13 | type deletePresetResponse struct { 14 | baseResponse 15 | } 16 | 17 | // error returned when the given preset data is not valid. 18 | // 19 | // swagger:response invalidPreset 20 | type invalidPresetResponse struct { 21 | // in: body 22 | Error *swagger.ErrorResponse 23 | } 24 | 25 | func newInvalidPresetResponse(err error) *invalidPresetResponse { 26 | return &invalidPresetResponse{Error: swagger.NewErrorResponse(err).WithStatus(http.StatusBadRequest)} 27 | } 28 | 29 | func (r *invalidPresetResponse) Result() (int, interface{}, error) { 30 | return r.Error.Result() 31 | } 32 | -------------------------------------------------------------------------------- /service/preset_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/NYTimes/gizmo/server" 12 | "github.com/sirupsen/logrus" 13 | "github.com/video-dev/video-transcoding-api/v2/config" 14 | "github.com/video-dev/video-transcoding-api/v2/db" 15 | "github.com/video-dev/video-transcoding-api/v2/db/dbtest" 16 | ) 17 | 18 | func TestNewPreset(t *testing.T) { 19 | tests := []struct { 20 | givenTestCase string 21 | givenRequestData map[string]interface{} 22 | wantOutputOpts db.OutputOptions 23 | wantBody map[string]interface{} 24 | wantCode int 25 | }{ 26 | { 27 | "Create new preset", 28 | map[string]interface{}{ 29 | "providers": []string{"fake", "encodingcom"}, 30 | "outputOptions": map[string]interface{}{ 31 | "extension": "mp5", 32 | }, 33 | "preset": map[string]interface{}{ 34 | "name": "nyt_test_here_2wq", 35 | "description": "testing creation from api", 36 | "container": "mp4", 37 | "profile": "Main", 38 | "profileLevel": "3.1", 39 | "rateControl": "VBR", 40 | "video": map[string]string{ 41 | "height": "720", 42 | "codec": "h264", 43 | "bitrate": "1000", 44 | "gopSize": "90", 45 | "gopMode": "fixed", 46 | "interlaceMode": "progressive", 47 | }, 48 | "audio": map[string]string{ 49 | "codec": "aac", 50 | "bitrate": "64000", 51 | }, 52 | }, 53 | }, 54 | db.OutputOptions{ 55 | Extension: "mp4", 56 | }, 57 | map[string]interface{}{ 58 | "Results": map[string]interface{}{ 59 | "fake": map[string]interface{}{ 60 | "PresetID": "presetID_here", 61 | "Error": "", 62 | }, 63 | "encodingcom": map[string]interface{}{ 64 | "PresetID": "", 65 | "Error": "getting factory: provider not found", 66 | }, 67 | }, 68 | "PresetMap": "nyt_test_here_2wq", 69 | }, 70 | http.StatusOK, 71 | }, 72 | { 73 | "Error creating preset in all providers", 74 | map[string]interface{}{ 75 | "providers": []string{"elastictranscoder", "encodingcom"}, 76 | "outputOptions": map[string]interface{}{ 77 | "extension": "mp5", 78 | }, 79 | "preset": map[string]interface{}{ 80 | "name": "nyt_test_here_3wq", 81 | "description": "testing creation from api", 82 | "container": "mp4", 83 | "profile": "Main", 84 | "profileLevel": "3.1", 85 | "rateControl": "VBR", 86 | "video": map[string]string{ 87 | "height": "720", 88 | "codec": "h264", 89 | "bitrate": "1000", 90 | "gopSize": "90", 91 | "gopMode": "fixed", 92 | "interlaceMode": "progressive", 93 | }, 94 | "audio": map[string]string{ 95 | "codec": "aac", 96 | "bitrate": "64000", 97 | }, 98 | }, 99 | }, 100 | db.OutputOptions{}, 101 | map[string]interface{}{ 102 | "Results": map[string]interface{}{ 103 | "elastictranscoder": map[string]interface{}{ 104 | "PresetID": "", 105 | "Error": "getting factory: provider not found", 106 | }, 107 | "encodingcom": map[string]interface{}{ 108 | "PresetID": "", 109 | "Error": "getting factory: provider not found", 110 | }, 111 | }, 112 | "PresetMap": "", 113 | }, 114 | http.StatusInternalServerError, 115 | }, 116 | } 117 | 118 | for _, test := range tests { 119 | name := test.givenRequestData["preset"].(map[string]interface{})["name"].(string) 120 | srvr := server.NewSimpleServer(&server.Config{}) 121 | fakeDB := dbtest.NewFakeRepository(false) 122 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | service.db = fakeDB 127 | srvr.Register(service) 128 | body, _ := json.Marshal(test.givenRequestData) 129 | r, _ := http.NewRequest("POST", "/presets", bytes.NewReader(body)) 130 | r.Header.Set("Content-Type", "application/json") 131 | w := httptest.NewRecorder() 132 | srvr.ServeHTTP(w, r) 133 | if w.Code != test.wantCode { 134 | t.Errorf("%s: wrong response code. Want %d. Got %d", test.givenTestCase, test.wantCode, w.Code) 135 | } 136 | var got map[string]interface{} 137 | err = json.NewDecoder(w.Body).Decode(&got) 138 | if err != nil { 139 | t.Errorf("%s: unable to JSON decode response body: %s", test.givenTestCase, err) 140 | } 141 | if !reflect.DeepEqual(got, test.wantBody) { 142 | t.Errorf("%s: expected response body of\n%#v;\ngot\n%#v", test.givenTestCase, test.wantBody, got) 143 | } 144 | if test.wantCode == http.StatusOK { 145 | presetMap, err := fakeDB.GetPresetMap(name) 146 | if err != nil { 147 | t.Fatalf("%s: %s", test.givenTestCase, err) 148 | } 149 | if !reflect.DeepEqual(presetMap.OutputOpts, test.wantOutputOpts) { 150 | t.Errorf("%s: wrong output options saved.\nWant %#v\nGot %#v", test.givenTestCase, test.wantOutputOpts, presetMap.OutputOpts) 151 | } 152 | } 153 | } 154 | } 155 | 156 | func TestNewPresetWithExistentPresetMap(t *testing.T) { 157 | data := map[string]interface{}{ 158 | "providers": []string{"zencoder"}, 159 | "outputOptions": map[string]interface{}{}, 160 | "preset": map[string]interface{}{ 161 | "name": "presetID_here", 162 | "description": "testing creation from api", 163 | "container": "mp4", 164 | "profile": "Main", 165 | "profileLevel": "3.1", 166 | "rateControl": "VBR", 167 | "video": map[string]string{ 168 | "height": "720", 169 | "codec": "h264", 170 | "bitrate": "1000", 171 | "gopSize": "90", 172 | "gopMode": "fixed", 173 | "interlaceMode": "progressive", 174 | }, 175 | "audio": map[string]string{ 176 | "codec": "aac", 177 | "bitrate": "64000", 178 | }, 179 | }, 180 | } 181 | 182 | presetMap := db.PresetMap{ 183 | Name: "presetID_here", 184 | ProviderMapping: map[string]string{ 185 | "fake": "presetID_here", 186 | }, 187 | OutputOpts: db.OutputOptions{}, 188 | } 189 | 190 | fakeDB := dbtest.NewFakeRepository(false) 191 | err := fakeDB.CreatePresetMap(&presetMap) 192 | if err != nil { 193 | t.Fatal(err) 194 | } 195 | 196 | srvr := server.NewSimpleServer(&server.Config{}) 197 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 198 | if err != nil { 199 | t.Fatal(err) 200 | } 201 | service.db = fakeDB 202 | srvr.Register(service) 203 | body, _ := json.Marshal(data) 204 | r, _ := http.NewRequest("POST", "/presets", bytes.NewReader(body)) 205 | r.Header.Set("Content-Type", "application/json") 206 | w := httptest.NewRecorder() 207 | srvr.ServeHTTP(w, r) 208 | 209 | if w.Code != http.StatusOK { 210 | t.Errorf("wrong response code. Want %d. Got %d", http.StatusOK, w.Code) 211 | } 212 | 213 | var got map[string]interface{} 214 | err = json.NewDecoder(w.Body).Decode(&got) 215 | if err != nil { 216 | t.Errorf("%s: unable to JSON decode response body: %s", w.Body, err) 217 | } 218 | 219 | expectedBody := map[string]interface{}{ 220 | "Results": map[string]interface{}{ 221 | "fake": map[string]interface{}{"PresetID": "presetID_here", "Error": ""}, 222 | "zencoder": map[string]interface{}{"PresetID": "presetID_here", "Error": ""}, 223 | }, 224 | "PresetMap": "presetID_here", 225 | } 226 | 227 | if !reflect.DeepEqual(got, expectedBody) { 228 | t.Errorf("expected response body of\n%#v;\ngot\n%#v", expectedBody, got) 229 | } 230 | } 231 | 232 | func TestDeletePreset(t *testing.T) { 233 | tests := []struct { 234 | givenTestCase string 235 | wantBody map[string]interface{} 236 | wantCode int 237 | }{ 238 | { 239 | "Delete a preset", 240 | map[string]interface{}{ 241 | "results": map[string]interface{}{ 242 | "fake": map[string]interface{}{ 243 | "presetId": "presetID_here", 244 | }, 245 | }, 246 | "presetMap": "removed successfully", 247 | }, 248 | http.StatusOK, 249 | }, 250 | } 251 | 252 | for _, test := range tests { 253 | srvr := server.NewSimpleServer(&server.Config{}) 254 | fakeDB := dbtest.NewFakeRepository(false) 255 | fakeProviderMapping := make(map[string]string) 256 | fakeProviderMapping["fake"] = "presetID_here" 257 | fakeDB.CreatePresetMap(&db.PresetMap{Name: "abc-321", ProviderMapping: fakeProviderMapping}) 258 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 259 | if err != nil { 260 | t.Fatal(err) 261 | } 262 | service.db = fakeDB 263 | srvr.Register(service) 264 | r, _ := http.NewRequest("DELETE", "/presets/abc-321", nil) 265 | r.Header.Set("Content-Type", "application/json") 266 | w := httptest.NewRecorder() 267 | srvr.ServeHTTP(w, r) 268 | if w.Code != test.wantCode { 269 | t.Errorf("%s: wrong response code. Want %d. Got %d", test.givenTestCase, test.wantCode, w.Code) 270 | } 271 | var got map[string]interface{} 272 | err = json.NewDecoder(w.Body).Decode(&got) 273 | if err != nil { 274 | t.Errorf("%s: unable to JSON decode response body: %s", test.givenTestCase, err) 275 | } 276 | if !reflect.DeepEqual(got, test.wantBody) { 277 | t.Errorf("%s: expected response body of\n%#v;\ngot\n%#v", test.givenTestCase, test.wantBody, got) 278 | } 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /service/presetmap.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/NYTimes/gizmo/server" 7 | "github.com/video-dev/video-transcoding-api/v2/db" 8 | "github.com/video-dev/video-transcoding-api/v2/swagger" 9 | ) 10 | 11 | // swagger:route POST /presetmaps presets newPreset 12 | // 13 | // Creates a new preset in the API. 14 | // 15 | // Responses: 16 | // 200: preset 17 | // 400: invalidPreset 18 | // 409: presetAlreadyExists 19 | // 500: genericError 20 | func (s *TranscodingService) newPresetMap(r *http.Request) swagger.GizmoJSONResponse { 21 | var input newPresetMapInput 22 | defer r.Body.Close() 23 | preset, err := input.PresetMap(r.Body) 24 | if err != nil { 25 | return newInvalidPresetMapResponse(err) 26 | } 27 | err = s.db.CreatePresetMap(&preset) 28 | switch err { 29 | case nil: 30 | return newPresetMapResponse(&preset) 31 | case db.ErrPresetMapAlreadyExists: 32 | return newPresetMapAlreadyExistsResponse(err) 33 | default: 34 | return swagger.NewErrorResponse(err) 35 | } 36 | } 37 | 38 | // swagger:route GET /presetmaps/{name} presets getPreset 39 | // 40 | // Finds a preset using its name. 41 | // 42 | // Responses: 43 | // 200: preset 44 | // 404: presetNotFound 45 | // 500: genericError 46 | func (s *TranscodingService) getPresetMap(r *http.Request) swagger.GizmoJSONResponse { 47 | var params getPresetMapInput 48 | params.loadParams(server.Vars(r)) 49 | preset, err := s.db.GetPresetMap(params.Name) 50 | 51 | switch err { 52 | case nil: 53 | return newPresetMapResponse(preset) 54 | case db.ErrPresetMapNotFound: 55 | return newPresetMapNotFoundResponse(err) 56 | default: 57 | return swagger.NewErrorResponse(err) 58 | } 59 | } 60 | 61 | // swagger:route PUT /presetmaps/{name} presets updatePreset 62 | // 63 | // Updates a presetmap using its name. 64 | // 65 | // Responses: 66 | // 200: preset 67 | // 400: invalidPreset 68 | // 404: presetNotFound 69 | // 500: genericError 70 | func (s *TranscodingService) updatePresetMap(r *http.Request) swagger.GizmoJSONResponse { 71 | defer r.Body.Close() 72 | var input updatePresetMapInput 73 | presetMap, err := input.PresetMap(server.Vars(r), r.Body) 74 | if err != nil { 75 | return newInvalidPresetMapResponse(err) 76 | } 77 | err = s.db.UpdatePresetMap(&presetMap) 78 | 79 | switch err { 80 | case nil: 81 | updatedPresetMap, _ := s.db.GetPresetMap(presetMap.Name) 82 | return newPresetMapResponse(updatedPresetMap) 83 | case db.ErrPresetMapNotFound: 84 | return newPresetMapNotFoundResponse(err) 85 | default: 86 | return swagger.NewErrorResponse(err) 87 | } 88 | } 89 | 90 | // swagger:route DELETE /presetmaps/{name} presets deletePresetMap 91 | // 92 | // Deletes a presetmap by name. 93 | // 94 | // Responses: 95 | // 200: emptyResponse 96 | // 404: presetNotFound 97 | // 500: genericError 98 | func (s *TranscodingService) deletePresetMap(r *http.Request) swagger.GizmoJSONResponse { 99 | var params getPresetMapInput 100 | params.loadParams(server.Vars(r)) 101 | err := s.db.DeletePresetMap(&db.PresetMap{Name: params.Name}) 102 | 103 | switch err { 104 | case nil: 105 | return emptyResponse(http.StatusOK) 106 | case db.ErrPresetMapNotFound: 107 | return newPresetMapNotFoundResponse(err) 108 | default: 109 | return swagger.NewErrorResponse(err) 110 | } 111 | } 112 | 113 | // swagger:route GET /presetmaps presets listPresetMaps 114 | // 115 | // List available presets on the API. 116 | // 117 | // Responses: 118 | // 200: listPresetMaps 119 | // 500: genericError 120 | func (s *TranscodingService) listPresetMaps(*http.Request) swagger.GizmoJSONResponse { 121 | presetsMap, err := s.db.ListPresetMaps() 122 | if err != nil { 123 | return swagger.NewErrorResponse(err) 124 | } 125 | return newListPresetMapsResponse(presetsMap) 126 | } 127 | -------------------------------------------------------------------------------- /service/presetmap_params.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/video-dev/video-transcoding-api/v2/db" 11 | "github.com/video-dev/video-transcoding-api/v2/swagger" 12 | ) 13 | 14 | // JSON-encoded preset returned on the newPreset and getPreset operations. 15 | // 16 | // swagger:response preset 17 | type presetMapResponse struct { 18 | // in: body 19 | Payload *db.PresetMap 20 | 21 | baseResponse 22 | } 23 | 24 | // swagger:parameters getPreset deletePreset deletePresetMap 25 | type getPresetMapInput struct { 26 | // in: path 27 | // required: true 28 | Name string `json:"name"` 29 | } 30 | 31 | // swagger:parameters updatePreset 32 | type updatePresetMapInput struct { 33 | // in: path 34 | // required: true 35 | Name string `json:"name"` 36 | 37 | // in: body 38 | // required: true 39 | Payload db.PresetMap 40 | 41 | newPresetMapInput 42 | } 43 | 44 | // swagger:parameters newPreset 45 | type newPresetMapInput struct { 46 | // in: body 47 | // required: true 48 | Payload db.PresetMap 49 | } 50 | 51 | // error returned when the given preset name is not found on the API (either on 52 | // getPreset or deletePreset operations). 53 | // 54 | // swagger:response presetNotFound 55 | type presetMapNotFoundResponse struct { 56 | // in: body 57 | Error *swagger.ErrorResponse 58 | } 59 | 60 | // error returned when the given preset data is not valid. 61 | // 62 | // swagger:response invalidPreset 63 | type invalidPresetMapResponse struct { 64 | // in: body 65 | Error *swagger.ErrorResponse 66 | } 67 | 68 | // error returned when trying to create a new preset using a name that is 69 | // already in-use. 70 | // 71 | // swagger:response presetAlreadyExists 72 | type presetMapAlreadyExistsResponse struct { 73 | // in: body 74 | Error *swagger.ErrorResponse 75 | } 76 | 77 | // response for the listPresetMaps operation. It's actually a JSON-encoded object 78 | // instead of an array, in the format `presetName: presetObject` 79 | // 80 | // swagger:response listPresetMaps 81 | type listPresetMapsResponse struct { 82 | // in: body 83 | PresetMaps map[string]db.PresetMap 84 | 85 | baseResponse 86 | } 87 | 88 | func newPresetMapResponse(preset *db.PresetMap) *presetMapResponse { 89 | return &presetMapResponse{ 90 | baseResponse: baseResponse{ 91 | payload: preset, 92 | status: http.StatusOK, 93 | }, 94 | } 95 | } 96 | 97 | func newPresetMapNotFoundResponse(err error) *presetMapNotFoundResponse { 98 | return &presetMapNotFoundResponse{Error: swagger.NewErrorResponse(err).WithStatus(http.StatusNotFound)} 99 | } 100 | 101 | func (r *presetMapNotFoundResponse) Result() (int, interface{}, error) { 102 | return r.Error.Result() 103 | } 104 | 105 | func newInvalidPresetMapResponse(err error) *invalidPresetMapResponse { 106 | return &invalidPresetMapResponse{Error: swagger.NewErrorResponse(err).WithStatus(http.StatusBadRequest)} 107 | } 108 | 109 | func (r *invalidPresetMapResponse) Result() (int, interface{}, error) { 110 | return r.Error.Result() 111 | } 112 | 113 | func newPresetMapAlreadyExistsResponse(err error) *presetMapAlreadyExistsResponse { 114 | return &presetMapAlreadyExistsResponse{Error: swagger.NewErrorResponse(err).WithStatus(http.StatusConflict)} 115 | } 116 | 117 | func (r *presetMapAlreadyExistsResponse) Result() (int, interface{}, error) { 118 | return r.Error.Result() 119 | } 120 | 121 | func newListPresetMapsResponse(presetsMap []db.PresetMap) *listPresetMapsResponse { 122 | Map := make(map[string]db.PresetMap, len(presetsMap)) 123 | for _, presetMap := range presetsMap { 124 | Map[presetMap.Name] = presetMap 125 | } 126 | return &listPresetMapsResponse{ 127 | baseResponse: baseResponse{ 128 | status: http.StatusOK, 129 | payload: Map, 130 | }, 131 | } 132 | } 133 | 134 | // Preset loads the input from the request body, validates them and returns the 135 | // preset. 136 | func (p *newPresetMapInput) PresetMap(body io.Reader) (db.PresetMap, error) { 137 | err := json.NewDecoder(body).Decode(&p.Payload) 138 | if err != nil { 139 | return p.Payload, err 140 | } 141 | err = validatePresetMap(&p.Payload) 142 | if err != nil { 143 | return p.Payload, err 144 | } 145 | err = p.Payload.OutputOpts.Validate() 146 | if err != nil { 147 | return p.Payload, fmt.Errorf("invalid output: %s", err) 148 | } 149 | return p.Payload, nil 150 | } 151 | 152 | func (p *getPresetMapInput) loadParams(paramsMap map[string]string) { 153 | p.Name = paramsMap["name"] 154 | } 155 | 156 | func (p *updatePresetMapInput) PresetMap(paramsMap map[string]string, body io.Reader) (db.PresetMap, error) { 157 | p.Name = paramsMap["name"] 158 | err := json.NewDecoder(body).Decode(&p.Payload) 159 | if err != nil { 160 | return p.Payload, err 161 | } 162 | p.Payload.Name = p.Name 163 | err = validatePresetMap(&p.Payload) 164 | if err != nil { 165 | return p.Payload, err 166 | } 167 | return p.Payload, nil 168 | } 169 | 170 | func validatePresetMap(p *db.PresetMap) error { 171 | if p.Name == "" { 172 | return errors.New("missing field name from the request") 173 | } 174 | if len(p.ProviderMapping) == 0 { 175 | return errors.New("missing field providerMapping from the request") 176 | } 177 | return nil 178 | } 179 | -------------------------------------------------------------------------------- /service/presetmap_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/NYTimes/gizmo/server" 12 | "github.com/sirupsen/logrus" 13 | "github.com/video-dev/video-transcoding-api/v2/config" 14 | "github.com/video-dev/video-transcoding-api/v2/db" 15 | "github.com/video-dev/video-transcoding-api/v2/db/dbtest" 16 | ) 17 | 18 | func TestNewPresetMap(t *testing.T) { 19 | tests := []struct { 20 | givenTestCase string 21 | givenRequestData map[string]interface{} 22 | givenTriggerDBError bool 23 | 24 | wantCode int 25 | wantBody map[string]interface{} 26 | }{ 27 | { 28 | "New presetmap", 29 | map[string]interface{}{ 30 | "name": "abc-123", 31 | "providerMapping": map[string]string{ 32 | "elementalconductor": "18", 33 | "elastictranscoder": "18384284-0002", 34 | }, 35 | "output": map[string]interface{}{ 36 | "extension": "mp4", 37 | }, 38 | }, 39 | false, 40 | 41 | http.StatusOK, 42 | map[string]interface{}{ 43 | "name": "abc-123", 44 | "providerMapping": map[string]interface{}{ 45 | "elementalconductor": "18", 46 | "elastictranscoder": "18384284-0002", 47 | }, 48 | "output": map[string]interface{}{ 49 | "extension": "mp4", 50 | }, 51 | }, 52 | }, 53 | { 54 | "New presetmap duplicate name", 55 | map[string]interface{}{ 56 | "name": "abc-321", 57 | "providerMapping": map[string]string{ 58 | "elementalconductor": "18", 59 | "elastictranscoder": "18384284-0002", 60 | }, 61 | "output": map[string]interface{}{ 62 | "extension": "mp4", 63 | }, 64 | }, 65 | false, 66 | 67 | http.StatusConflict, 68 | map[string]interface{}{ 69 | "error": db.ErrPresetMapAlreadyExists.Error(), 70 | }, 71 | }, 72 | { 73 | "New presetmap missing name", 74 | map[string]interface{}{ 75 | "providerMapping": map[string]string{ 76 | "elementalconductor": "18", 77 | "elastictranscoder": "18384284-0002", 78 | }, 79 | }, 80 | false, 81 | 82 | http.StatusBadRequest, 83 | map[string]interface{}{ 84 | "error": "missing field name from the request", 85 | }, 86 | }, 87 | { 88 | "New presetmap missing extension", 89 | map[string]interface{}{ 90 | "name": "abc-123", 91 | "providerMapping": map[string]string{ 92 | "elementalconductor": "18", 93 | "elastictranscoder": "18384284-0002", 94 | }, 95 | }, 96 | false, 97 | 98 | http.StatusBadRequest, 99 | map[string]interface{}{ 100 | "error": "invalid output: extension is required", 101 | }, 102 | }, 103 | { 104 | "New preset missing providers", 105 | map[string]interface{}{ 106 | "name": "mypreset", 107 | "providerMapping": nil, 108 | }, 109 | false, 110 | 111 | http.StatusBadRequest, 112 | map[string]interface{}{ 113 | "error": "missing field providerMapping from the request", 114 | }, 115 | }, 116 | { 117 | "New preset DB failure", 118 | map[string]interface{}{ 119 | "name": "super-preset", 120 | "providerMapping": map[string]string{ 121 | "elementalconductor": "18", 122 | "elastictranscoder": "18384284-0002", 123 | }, 124 | "output": map[string]string{ 125 | "extension": "mp4", 126 | }, 127 | }, 128 | true, 129 | 130 | http.StatusInternalServerError, 131 | map[string]interface{}{"error": "database error"}, 132 | }, 133 | } 134 | for _, test := range tests { 135 | srvr := server.NewSimpleServer(&server.Config{}) 136 | fakeDB := dbtest.NewFakeRepository(test.givenTriggerDBError) 137 | fakeDB.CreatePresetMap(&db.PresetMap{Name: "abc-321"}) 138 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 139 | if err != nil { 140 | t.Fatal(err) 141 | } 142 | service.db = fakeDB 143 | srvr.Register(service) 144 | body, _ := json.Marshal(test.givenRequestData) 145 | r, _ := http.NewRequest("POST", "/presetmaps", bytes.NewReader(body)) 146 | r.Header.Set("Content-Type", "application/json") 147 | w := httptest.NewRecorder() 148 | srvr.ServeHTTP(w, r) 149 | if w.Code != test.wantCode { 150 | t.Errorf("%s: wrong response code. Want %d. Got %d", test.givenTestCase, test.wantCode, w.Code) 151 | } 152 | var got map[string]interface{} 153 | err = json.NewDecoder(w.Body).Decode(&got) 154 | if err != nil { 155 | t.Errorf("%s: unable to JSON decode response body: %s", test.givenTestCase, err) 156 | } 157 | if !reflect.DeepEqual(got, test.wantBody) { 158 | t.Errorf("%s: expected response body of\n%#v;\ngot\n%#v", test.givenTestCase, test.wantBody, got) 159 | } 160 | if test.wantCode == http.StatusOK { 161 | presetmap, err := fakeDB.GetPresetMap(got["name"].(string)) 162 | if err != nil { 163 | t.Error(err) 164 | } else if !reflect.DeepEqual(presetmap.ProviderMapping, test.givenRequestData["providerMapping"]) { 165 | t.Errorf("%s: didn't save the preset in the database. Want %#v. Got %#v", test.givenTestCase, test.givenRequestData, presetmap.ProviderMapping) 166 | } 167 | } 168 | } 169 | } 170 | 171 | func TestGetPresetMap(t *testing.T) { 172 | tests := []struct { 173 | givenTestCase string 174 | givenPresetMapName string 175 | 176 | wantBody *db.PresetMap 177 | wantCode int 178 | }{ 179 | { 180 | "Get preset", 181 | "preset-1", 182 | &db.PresetMap{Name: "preset-1"}, 183 | http.StatusOK, 184 | }, 185 | { 186 | "Get presetmap not found", 187 | "preset-unknown", 188 | nil, 189 | http.StatusNotFound, 190 | }, 191 | } 192 | for _, test := range tests { 193 | srvr := server.NewSimpleServer(&server.Config{}) 194 | fakeDB := dbtest.NewFakeRepository(false) 195 | fakeDB.CreatePresetMap(&db.PresetMap{Name: "preset-1"}) 196 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 197 | if err != nil { 198 | t.Fatal(err) 199 | } 200 | service.db = fakeDB 201 | srvr.Register(service) 202 | r, _ := http.NewRequest("GET", "/presetmaps/"+test.givenPresetMapName, nil) 203 | w := httptest.NewRecorder() 204 | srvr.ServeHTTP(w, r) 205 | if w.Code != test.wantCode { 206 | t.Errorf("%s: wrong response code. Want %d. Got %d", test.givenTestCase, test.wantCode, w.Code) 207 | } 208 | if test.wantBody != nil { 209 | var gotPresetMap db.PresetMap 210 | err := json.NewDecoder(w.Body).Decode(&gotPresetMap) 211 | if err != nil { 212 | t.Fatal(err) 213 | } 214 | if !reflect.DeepEqual(gotPresetMap, *test.wantBody) { 215 | t.Errorf("%s: wrong body. Want %#v. Got %#v", test.givenTestCase, *test.wantBody, gotPresetMap) 216 | } 217 | } 218 | } 219 | } 220 | 221 | func TestUpdatePresetMap(t *testing.T) { 222 | tests := []struct { 223 | givenTestCase string 224 | givenPresetMapName string 225 | givenRequestData map[string]interface{} 226 | 227 | wantBody *db.PresetMap 228 | wantCode int 229 | }{ 230 | { 231 | "Update preset", 232 | "preset-1", 233 | map[string]interface{}{ 234 | "providerMapping": map[string]string{ 235 | "elementalconductor": "abc-123", 236 | "elastictranscoder": "def-345", 237 | }, 238 | }, 239 | &db.PresetMap{ 240 | Name: "preset-1", 241 | ProviderMapping: map[string]string{ 242 | "elementalconductor": "abc-123", 243 | "elastictranscoder": "def-345", 244 | }, 245 | }, 246 | http.StatusOK, 247 | }, 248 | { 249 | "Update presetmap not found", 250 | "preset-unknown", 251 | map[string]interface{}{ 252 | "providerMapping": map[string]string{ 253 | "elementalconductor": "abc-123", 254 | "elastictranscoder": "def-345", 255 | }, 256 | }, 257 | nil, 258 | http.StatusNotFound, 259 | }, 260 | } 261 | for _, test := range tests { 262 | srvr := server.NewSimpleServer(&server.Config{}) 263 | fakeDB := dbtest.NewFakeRepository(false) 264 | fakeDB.CreatePresetMap(&db.PresetMap{ 265 | Name: "preset-1", 266 | ProviderMapping: map[string]string{ 267 | "elementalconductor": "some-id", 268 | }, 269 | }) 270 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 271 | if err != nil { 272 | t.Fatal(err) 273 | } 274 | service.db = fakeDB 275 | srvr.Register(service) 276 | data, _ := json.Marshal(test.givenRequestData) 277 | r, _ := http.NewRequest("PUT", "/presetmaps/"+test.givenPresetMapName, bytes.NewReader(data)) 278 | w := httptest.NewRecorder() 279 | srvr.ServeHTTP(w, r) 280 | if w.Code != test.wantCode { 281 | t.Errorf("%s: wrong response code. Want %d. Got %d", test.givenTestCase, test.wantCode, w.Code) 282 | } 283 | if test.wantBody != nil { 284 | var gotPresetMap db.PresetMap 285 | err := json.NewDecoder(w.Body).Decode(&gotPresetMap) 286 | if err != nil { 287 | t.Fatal(err) 288 | } 289 | if !reflect.DeepEqual(gotPresetMap, *test.wantBody) { 290 | t.Errorf("%s: wrong body. Want %#v. Got %#v", test.givenTestCase, *test.wantBody, gotPresetMap) 291 | } 292 | preset, err := fakeDB.GetPresetMap(gotPresetMap.Name) 293 | if err != nil { 294 | t.Error(err) 295 | } else if !reflect.DeepEqual(*preset, gotPresetMap) { 296 | t.Errorf("%s: didn't update the preset in the database. Want %#v. Got %#v", test.givenTestCase, gotPresetMap, *preset) 297 | } 298 | } 299 | } 300 | } 301 | 302 | func TestDeletePresetMap(t *testing.T) { 303 | tests := []struct { 304 | givenTestCase string 305 | givenPresetMapName string 306 | wantCode int 307 | }{ 308 | { 309 | "Delete preset", 310 | "preset-1", 311 | http.StatusOK, 312 | }, 313 | { 314 | "Delete presetmap not found", 315 | "preset-unknown", 316 | http.StatusNotFound, 317 | }, 318 | } 319 | for _, test := range tests { 320 | srvr := server.NewSimpleServer(&server.Config{}) 321 | fakeDB := dbtest.NewFakeRepository(false) 322 | fakeDB.CreatePresetMap(&db.PresetMap{Name: "preset-1"}) 323 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 324 | if err != nil { 325 | t.Fatal(err) 326 | } 327 | service.db = fakeDB 328 | srvr.Register(service) 329 | r, _ := http.NewRequest("DELETE", "/presetmaps/"+test.givenPresetMapName, nil) 330 | w := httptest.NewRecorder() 331 | srvr.ServeHTTP(w, r) 332 | if w.Code != test.wantCode { 333 | t.Errorf("%s: wrong response code. Want %d. Got %d", test.givenTestCase, test.wantCode, w.Code) 334 | } 335 | if test.wantCode == http.StatusOK { 336 | _, err := fakeDB.GetPresetMap(test.givenPresetMapName) 337 | if err != db.ErrPresetMapNotFound { 338 | t.Errorf("%s: didn't delete the job in the database", test.givenTestCase) 339 | } 340 | } 341 | } 342 | } 343 | 344 | func TestListPresetMaps(t *testing.T) { 345 | tests := []struct { 346 | givenTestCase string 347 | givenPresetMaps []db.PresetMap 348 | 349 | wantCode int 350 | wantBody map[string]db.PresetMap 351 | }{ 352 | { 353 | "List presets", 354 | []db.PresetMap{ 355 | { 356 | Name: "preset-1", 357 | ProviderMapping: map[string]string{"elementalconductor": "abc123"}, 358 | }, 359 | { 360 | Name: "preset-2", 361 | ProviderMapping: map[string]string{"elementalconductor": "abc124"}, 362 | }, 363 | { 364 | Name: "preset-3", 365 | ProviderMapping: map[string]string{"elementalconductor": "abc125"}, 366 | }, 367 | }, 368 | http.StatusOK, 369 | map[string]db.PresetMap{ 370 | "preset-1": { 371 | Name: "preset-1", 372 | ProviderMapping: map[string]string{"elementalconductor": "abc123"}, 373 | }, 374 | "preset-2": { 375 | Name: "preset-2", 376 | ProviderMapping: map[string]string{"elementalconductor": "abc124"}, 377 | }, 378 | "preset-3": { 379 | Name: "preset-3", 380 | ProviderMapping: map[string]string{"elementalconductor": "abc125"}, 381 | }, 382 | }, 383 | }, 384 | { 385 | "Empty list of presets", 386 | nil, 387 | http.StatusOK, 388 | map[string]db.PresetMap{}, 389 | }, 390 | } 391 | for _, test := range tests { 392 | srvr := server.NewSimpleServer(&server.Config{}) 393 | fakeDB := dbtest.NewFakeRepository(false) 394 | for i := range test.givenPresetMaps { 395 | fakeDB.CreatePresetMap(&test.givenPresetMaps[i]) 396 | } 397 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 398 | if err != nil { 399 | t.Fatal(err) 400 | } 401 | service.db = fakeDB 402 | srvr.Register(service) 403 | r, _ := http.NewRequest("GET", "/presetmaps", nil) 404 | w := httptest.NewRecorder() 405 | srvr.ServeHTTP(w, r) 406 | if w.Code != test.wantCode { 407 | t.Errorf("%s: wrong response code. Want %d. Got %d", test.givenTestCase, test.wantCode, w.Code) 408 | } 409 | var got map[string]db.PresetMap 410 | err = json.NewDecoder(w.Body).Decode(&got) 411 | if err != nil { 412 | t.Errorf("%s: unable to JSON decode response body: %s", test.givenTestCase, err) 413 | } 414 | if !reflect.DeepEqual(got, test.wantBody) { 415 | t.Errorf("%s: expected response body of\n%#v;\ngot\n%#v", test.givenTestCase, test.wantBody, got) 416 | } 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /service/provider.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/NYTimes/gizmo/server" 7 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 8 | "github.com/video-dev/video-transcoding-api/v2/swagger" 9 | ) 10 | 11 | // swagger:route GET /providers providers listProviders 12 | // 13 | // Describe available providers in the API, including their name, capabilities 14 | // and health state. 15 | // 16 | // Responses: 17 | // 200: listProviders 18 | // 500: genericError 19 | func (s *TranscodingService) listProviders(*http.Request) swagger.GizmoJSONResponse { 20 | return newListProvidersResponse(provider.ListProviders(s.config)) 21 | } 22 | 23 | // swagger:route GET /providers/{name} providers getProvider 24 | // 25 | // Describe available providers in the API, including their name, capabilities 26 | // and health state. 27 | // 28 | // Responses: 29 | // 200: provider 30 | // 404: providerNotFound 31 | // 500: genericError 32 | func (s *TranscodingService) getProvider(r *http.Request) swagger.GizmoJSONResponse { 33 | var params getProviderInput 34 | params.loadParams(server.Vars(r)) 35 | description, err := provider.DescribeProvider(params.Name, s.config) 36 | switch err { 37 | case nil: 38 | return newGetProviderResponse(description) 39 | case provider.ErrProviderNotFound: 40 | return newProviderNotFoundResponse(err) 41 | default: 42 | return swagger.NewErrorResponse(err) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /service/provider_params.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | // swagger:parameters getProvider deleteProvider 4 | type getProviderInput struct { 5 | // in: path 6 | // required: true 7 | Name string `json:"name"` 8 | } 9 | 10 | func (p *getProviderInput) loadParams(paramsMap map[string]string) { 11 | p.Name = paramsMap["name"] 12 | } 13 | -------------------------------------------------------------------------------- /service/provider_responses.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 7 | "github.com/video-dev/video-transcoding-api/v2/swagger" 8 | ) 9 | 10 | // response for the listProviders operation. Contains the list of providers 11 | // alphabetically ordered. 12 | // 13 | // swagger:response listProviders 14 | type listProvidersResponse struct { 15 | // in: body 16 | Providers []string 17 | 18 | baseResponse 19 | } 20 | 21 | func newListProvidersResponse(providerNames []string) *listProvidersResponse { 22 | return &listProvidersResponse{ 23 | baseResponse: baseResponse{payload: providerNames, status: http.StatusOK}, 24 | } 25 | } 26 | 27 | // response for the getProvider operation. 28 | // 29 | // swagger:response provider 30 | type getProviderResponse struct { 31 | // in: body 32 | Provider *provider.Description 33 | 34 | baseResponse 35 | } 36 | 37 | func newGetProviderResponse(p *provider.Description) *getProviderResponse { 38 | return &getProviderResponse{ 39 | baseResponse: baseResponse{payload: p, status: http.StatusOK}, 40 | } 41 | } 42 | 43 | // error returned when the given provider name is not found in the API. 44 | // 45 | // swagger:response providerNotFound 46 | type providerNotFoundResponse struct { 47 | // in: body 48 | Error *swagger.ErrorResponse 49 | } 50 | 51 | func newProviderNotFoundResponse(err error) *providerNotFoundResponse { 52 | return &providerNotFoundResponse{Error: swagger.NewErrorResponse(err).WithStatus(http.StatusNotFound)} 53 | } 54 | 55 | func (r *providerNotFoundResponse) Result() (int, interface{}, error) { 56 | return r.Error.Result() 57 | } 58 | -------------------------------------------------------------------------------- /service/provider_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/NYTimes/gizmo/server" 11 | "github.com/sirupsen/logrus" 12 | "github.com/video-dev/video-transcoding-api/v2/config" 13 | ) 14 | 15 | func TestListProviders(t *testing.T) { 16 | srvr := server.NewSimpleServer(&server.Config{}) 17 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | srvr.Register(service) 22 | r, _ := http.NewRequest("GET", "/providers", nil) 23 | w := httptest.NewRecorder() 24 | srvr.ServeHTTP(w, r) 25 | if w.Code != http.StatusOK { 26 | t.Errorf("listProviders: wrong status code. Want %d. Got %d", http.StatusOK, w.Code) 27 | } 28 | var providers []string 29 | err = json.NewDecoder(w.Body).Decode(&providers) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | expected := []string{"fake", "zencoder"} 34 | if !reflect.DeepEqual(providers, expected) { 35 | t.Errorf("listProviders: wrong body. Want %#v. Got %#v", expected, providers) 36 | } 37 | } 38 | 39 | func TestGetProvider(t *testing.T) { 40 | tests := []struct { 41 | testCase string 42 | name string 43 | 44 | expectedStatus int 45 | expectedBody map[string]interface{} 46 | }{ 47 | { 48 | "get provider", 49 | "fake", 50 | http.StatusOK, 51 | map[string]interface{}{ 52 | "name": "fake", 53 | "health": map[string]interface{}{"ok": true}, 54 | "capabilities": map[string]interface{}{ 55 | "input": []interface{}{"prores", "h264"}, 56 | "output": []interface{}{"mp4", "webm", "hls"}, 57 | "destinations": []interface{}{"akamai", "s3"}, 58 | }, 59 | "enabled": true, 60 | }, 61 | }, 62 | { 63 | "provider not found", 64 | "whatever", 65 | http.StatusNotFound, 66 | map[string]interface{}{"error": "provider not found"}, 67 | }, 68 | } 69 | for _, test := range tests { 70 | srvr := server.NewSimpleServer(&server.Config{}) 71 | service, err := NewTranscodingService(&config.Config{Server: &server.Config{}}, logrus.New()) 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | srvr.Register(service) 76 | r, _ := http.NewRequest("GET", "/providers/"+test.name, nil) 77 | w := httptest.NewRecorder() 78 | srvr.ServeHTTP(w, r) 79 | if w.Code != test.expectedStatus { 80 | t.Errorf("%s: wrong status code. Want %d. Got %d", test.testCase, test.expectedStatus, w.Code) 81 | } 82 | var gotBody map[string]interface{} 83 | err = json.NewDecoder(w.Body).Decode(&gotBody) 84 | if err != nil { 85 | t.Errorf("%s: %s", test.testCase, err) 86 | } 87 | if !reflect.DeepEqual(gotBody, test.expectedBody) { 88 | t.Errorf("%s: wrong body.\nWant %#v.\nGot %#v", test.testCase, test.expectedBody, gotBody) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /service/response.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | type baseResponse struct { 4 | payload interface{} 5 | status int 6 | err error 7 | } 8 | 9 | func (r *baseResponse) Result() (int, interface{}, error) { 10 | return r.status, r.payload, r.err 11 | } 12 | 13 | // emptyResponse represents an empty response returned by the API, it's 14 | // composed only by the HTTP status code. 15 | // 16 | // swagger:response 17 | type emptyResponse int 18 | 19 | func (r emptyResponse) Result() (int, interface{}, error) { 20 | return int(r), nil, nil 21 | } 22 | -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/NYTimes/gizmo/server" 8 | "github.com/NYTimes/gziphandler" 9 | "github.com/fsouza/ctxlogger" 10 | "github.com/gorilla/handlers" 11 | "github.com/sirupsen/logrus" 12 | "github.com/video-dev/video-transcoding-api/v2/config" 13 | "github.com/video-dev/video-transcoding-api/v2/db" 14 | "github.com/video-dev/video-transcoding-api/v2/db/redis" 15 | "github.com/video-dev/video-transcoding-api/v2/swagger" 16 | ) 17 | 18 | // TranscodingService will implement server.JSONService and handle all requests 19 | // to the server. 20 | type TranscodingService struct { 21 | config *config.Config 22 | db db.Repository 23 | logger *logrus.Logger 24 | } 25 | 26 | // NewTranscodingService will instantiate a JSONService 27 | // with the given configuration. 28 | func NewTranscodingService(cfg *config.Config, logger *logrus.Logger) (*TranscodingService, error) { 29 | dbRepo, err := redis.NewRepository(cfg) 30 | if err != nil { 31 | return nil, fmt.Errorf("error initializing Redis client: %s", err) 32 | } 33 | return &TranscodingService{config: cfg, db: dbRepo, logger: logger}, nil 34 | } 35 | 36 | // Prefix returns the string prefix used for all endpoints within 37 | // this service. 38 | func (s *TranscodingService) Prefix() string { 39 | return "" 40 | } 41 | 42 | // Middleware provides an http.Handler hook wrapped around all requests. 43 | // In this implementation, we're using a GzipHandler middleware to 44 | // compress our responses. 45 | func (s *TranscodingService) Middleware(h http.Handler) http.Handler { 46 | logMiddleware := ctxlogger.ContextLogger(s.logger) 47 | h = logMiddleware(h) 48 | if s.config.Server.HTTPAccessLog == nil { 49 | h = handlers.LoggingHandler(s.logger.Writer(), h) 50 | } 51 | return gziphandler.GzipHandler(server.CORSHandler(h, "")) 52 | } 53 | 54 | // JSONMiddleware provides a JSONEndpoint hook wrapped around all requests. 55 | func (s *TranscodingService) JSONMiddleware(j server.JSONEndpoint) server.JSONEndpoint { 56 | return func(r *http.Request) (int, interface{}, error) { 57 | status, res, err := j(r) 58 | if err != nil { 59 | return swagger.NewErrorResponse(err).WithStatus(status).Result() 60 | } 61 | return status, res, nil 62 | } 63 | } 64 | 65 | // JSONEndpoints is a listing of all endpoints available in the JSONService. 66 | func (s *TranscodingService) JSONEndpoints() map[string]map[string]server.JSONEndpoint { 67 | return map[string]map[string]server.JSONEndpoint{ 68 | "/jobs": { 69 | "POST": swagger.HandlerToJSONEndpoint(s.newTranscodeJob), 70 | }, 71 | "/jobs/{jobId}": { 72 | "GET": swagger.HandlerToJSONEndpoint(s.getTranscodeJob), 73 | }, 74 | "/jobs/{jobId}/cancel": { 75 | "POST": swagger.HandlerToJSONEndpoint(s.cancelTranscodeJob), 76 | }, 77 | "/presets": { 78 | "POST": swagger.HandlerToJSONEndpoint(s.newPreset), 79 | }, 80 | "/presets/{name}": { 81 | "DELETE": swagger.HandlerToJSONEndpoint(s.deletePreset), 82 | }, 83 | "/presetmaps": { 84 | "POST": swagger.HandlerToJSONEndpoint(s.newPresetMap), 85 | "GET": swagger.HandlerToJSONEndpoint(s.listPresetMaps), 86 | }, 87 | "/presetmaps/{name}": { 88 | "GET": swagger.HandlerToJSONEndpoint(s.getPresetMap), 89 | "PUT": swagger.HandlerToJSONEndpoint(s.updatePresetMap), 90 | "DELETE": swagger.HandlerToJSONEndpoint(s.deletePresetMap), 91 | }, 92 | "/providers": { 93 | "GET": swagger.HandlerToJSONEndpoint(s.listProviders), 94 | }, 95 | "/providers/{name}": { 96 | "GET": swagger.HandlerToJSONEndpoint(s.getProvider), 97 | }, 98 | } 99 | } 100 | 101 | // Endpoints is a list of all non-json endpoints. 102 | func (s *TranscodingService) Endpoints() map[string]map[string]http.HandlerFunc { 103 | return map[string]map[string]http.HandlerFunc{ 104 | "/swagger.json": { 105 | "GET": s.swaggerManifest, 106 | }, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /service/swagger.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | ) 7 | 8 | func (s *TranscodingService) swaggerManifest(w http.ResponseWriter, _ *http.Request) { 9 | data, err := ioutil.ReadFile(s.config.SwaggerManifest) 10 | if err != nil { 11 | http.Error(w, err.Error(), http.StatusInternalServerError) 12 | return 13 | } 14 | 15 | // In order to support pure-JavaScript clients (like Swagger-UI), the 16 | // server must set CORS headers. 17 | w.Header().Set("Access-Control-Allow-Origin", "*") 18 | w.Header().Set("Access-Control-Allow-Methods", "GET") 19 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type") 20 | 21 | w.Header().Set("Content-Type", "application/json") 22 | w.Write(data) 23 | } 24 | -------------------------------------------------------------------------------- /service/swagger_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/NYTimes/gizmo/server" 10 | "github.com/sirupsen/logrus" 11 | "github.com/video-dev/video-transcoding-api/v2/config" 12 | ) 13 | 14 | func TestSwaggerManifest(t *testing.T) { 15 | expectedHeaders := map[string]string{ 16 | "Access-Control-Allow-Origin": "*", 17 | "Access-Control-Allow-Methods": "GET", 18 | "Access-Control-Allow-Headers": "Content-Type", 19 | "Content-Type": "application/json", 20 | } 21 | srvr := server.NewSimpleServer(nil) 22 | srvr.Register(&TranscodingService{ 23 | config: &config.Config{ 24 | SwaggerManifest: "testdata/swagger.json", 25 | Server: &server.Config{}, 26 | }, 27 | logger: logrus.New(), 28 | }) 29 | r, _ := http.NewRequest("GET", "/swagger.json", nil) 30 | w := httptest.NewRecorder() 31 | srvr.ServeHTTP(w, r) 32 | if w.Code != http.StatusOK { 33 | t.Errorf("Swagger manifest: wrong status code. Want %d. Got %d", http.StatusOK, w.Code) 34 | } 35 | for k, v := range expectedHeaders { 36 | got := w.Header().Get(k) 37 | if got != v { 38 | t.Errorf("Swagger manifest: wrong header value for key=%q. Want %q. Got %q", k, v, got) 39 | } 40 | } 41 | expectedData, err := ioutil.ReadFile("testdata/swagger.json") 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | if string(expectedData) != w.Body.String() { 46 | t.Errorf("Swagger manifest: wrong body\nWant: %s\nGot: %s", expectedData, w.Body) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /service/testdata/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "consumes": [ 3 | "application/json" 4 | ], 5 | "produces": [ 6 | "application/json" 7 | ], 8 | "schemes": [ 9 | "https" 10 | ], 11 | "swagger": "2.0", 12 | "info": { 13 | "description": "HTTP API for transcoding media files into different formats using pluggable\nproviders.\n\n##Currently supported providers\n\n+ [Amazon Elastic Transcoder](https://aws.amazon.com/elastictranscoder/)\n+ [Elemental Conductor](https://www.elementaltechnologies.com/products/elemental-conductor)\n+ [Encoding.com](http://api.encoding.com)", 14 | "title": "video-transcoding-api", 15 | "license": { 16 | "name": "Apache 2.0", 17 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 18 | }, 19 | "version": "1.0.0" 20 | }, 21 | "basePath": "/", 22 | "paths": {}, 23 | "definitions": {}, 24 | "responses": {} 25 | } 26 | -------------------------------------------------------------------------------- /service/transcode.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "path" 9 | "path/filepath" 10 | 11 | "github.com/NYTimes/gizmo/server" 12 | "github.com/video-dev/video-transcoding-api/v2/db" 13 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 14 | "github.com/video-dev/video-transcoding-api/v2/swagger" 15 | ) 16 | 17 | // swagger:route POST /jobs jobs newJob 18 | // 19 | // Creates a new transcoding job. 20 | // 21 | // Responses: 22 | // 200: job 23 | // 400: invalidJob 24 | // 500: genericError 25 | func (s *TranscodingService) newTranscodeJob(r *http.Request) swagger.GizmoJSONResponse { 26 | defer r.Body.Close() 27 | var input newTranscodeJobInput 28 | providerFactory, err := input.ProviderFactory(r.Body) 29 | if err != nil { 30 | return newInvalidJobResponse(err) 31 | } 32 | providerObj, err := providerFactory(s.config) 33 | if err != nil { 34 | formattedErr := fmt.Errorf("error initializing provider %s for new job: %v %s", input.Payload.Provider, providerObj, err) 35 | if _, ok := err.(provider.InvalidConfigError); ok { 36 | return newInvalidJobResponse(formattedErr) 37 | } 38 | return swagger.NewErrorResponse(formattedErr) 39 | } 40 | job := db.Job{ 41 | SourceMedia: input.Payload.Source, 42 | StreamingParams: input.Payload.StreamingParams, 43 | } 44 | outputs := make([]db.TranscodeOutput, len(input.Payload.Outputs)) 45 | for i, output := range input.Payload.Outputs { 46 | presetMap, presetErr := s.db.GetPresetMap(output.Preset) 47 | if presetErr != nil { 48 | if presetErr == db.ErrPresetMapNotFound { 49 | return newInvalidJobResponse(presetErr) 50 | } 51 | return swagger.NewErrorResponse(presetErr) 52 | } 53 | fileName := output.FileName 54 | if fileName == "" { 55 | fileName = s.defaultFileName(input.Payload.Source, presetMap) 56 | } 57 | outputs[i] = db.TranscodeOutput{FileName: fileName, Preset: *presetMap} 58 | } 59 | job.Outputs = outputs 60 | job.ID, err = s.genID() 61 | if err != nil { 62 | return swagger.NewErrorResponse(err) 63 | } 64 | if job.StreamingParams.Protocol == "hls" { 65 | if job.StreamingParams.PlaylistFileName == "" { 66 | job.StreamingParams.PlaylistFileName = "hls/index.m3u8" 67 | } 68 | if job.StreamingParams.SegmentDuration == 0 { 69 | job.StreamingParams.SegmentDuration = s.config.DefaultSegmentDuration 70 | } 71 | } 72 | jobStatus, err := providerObj.Transcode(&job) 73 | if err == provider.ErrPresetMapNotFound { 74 | return newInvalidJobResponse(err) 75 | } 76 | if err != nil { 77 | providerError := fmt.Errorf("error with provider %q: %s", input.Payload.Provider, err) 78 | return swagger.NewErrorResponse(providerError) 79 | } 80 | jobStatus.ProviderName = input.Payload.Provider 81 | job.ProviderName = jobStatus.ProviderName 82 | job.ProviderJobID = jobStatus.ProviderJobID 83 | err = s.db.CreateJob(&job) 84 | if err != nil { 85 | return swagger.NewErrorResponse(err) 86 | } 87 | return newJobResponse(job.ID) 88 | } 89 | 90 | func (s *TranscodingService) genID() (string, error) { 91 | var data [8]byte 92 | n, err := rand.Read(data[:]) 93 | if err != nil { 94 | return "", err 95 | } 96 | if n != len(data) { 97 | return "", io.ErrShortWrite 98 | } 99 | return fmt.Sprintf("%x", data), nil 100 | } 101 | 102 | func (s *TranscodingService) defaultFileName(source string, preset *db.PresetMap) string { 103 | sourceExtension := filepath.Ext(source) 104 | _, source = path.Split(source) 105 | source = source[:len(source)-len(sourceExtension)] 106 | pattern := "%s_%s.%s" 107 | if preset.OutputOpts.Extension == "m3u8" { 108 | pattern = "hls/" + pattern 109 | } 110 | return fmt.Sprintf(pattern, source, preset.Name, preset.OutputOpts.Extension) 111 | } 112 | 113 | // swagger:route GET /jobs/{jobId} jobs getJob 114 | // 115 | // Finds a trancode job using its ID. 116 | // It also queries the provider to get the status of the job. 117 | // 118 | // Responses: 119 | // 200: jobStatus 120 | // 404: jobNotFound 121 | // 410: jobNotFoundInTheProvider 122 | // 500: genericError 123 | func (s *TranscodingService) getTranscodeJob(r *http.Request) swagger.GizmoJSONResponse { 124 | var params getTranscodeJobInput 125 | params.loadParams(server.Vars(r)) 126 | return s.getJobStatusResponse(s.getTranscodeJobByID(params.JobID)) 127 | } 128 | 129 | func (s *TranscodingService) getJobStatusResponse(job *db.Job, status *provider.JobStatus, p provider.TranscodingProvider, err error) swagger.GizmoJSONResponse { 130 | if err != nil { 131 | if err == db.ErrJobNotFound { 132 | return newJobNotFoundResponse(err) 133 | } 134 | if p != nil { 135 | providerError := fmt.Errorf("error with provider %q when trying to retrieve job id %q: %s", job.ProviderName, job.ID, err) 136 | if _, ok := err.(provider.JobNotFoundError); ok { 137 | return newJobNotFoundProviderResponse(providerError) 138 | } 139 | return swagger.NewErrorResponse(providerError) 140 | } 141 | return swagger.NewErrorResponse(err) 142 | } 143 | return newJobStatusResponse(status) 144 | } 145 | 146 | func (s *TranscodingService) getTranscodeJobByID(jobID string) (*db.Job, *provider.JobStatus, provider.TranscodingProvider, error) { 147 | job, err := s.db.GetJob(jobID) 148 | if err != nil { 149 | if err == db.ErrJobNotFound { 150 | return nil, nil, nil, err 151 | } 152 | return nil, nil, nil, fmt.Errorf("error retrieving job with id %q: %s", jobID, err) 153 | } 154 | providerFactory, err := provider.GetProviderFactory(job.ProviderName) 155 | if err != nil { 156 | return job, nil, nil, fmt.Errorf("unknown provider %q for job id %q", job.ProviderName, jobID) 157 | } 158 | providerObj, err := providerFactory(s.config) 159 | if err != nil { 160 | return job, nil, nil, fmt.Errorf("error initializing provider %q on job id %q: %s %s", job.ProviderName, jobID, providerObj, err) 161 | } 162 | jobStatus, err := providerObj.JobStatus(job) 163 | if err != nil { 164 | return job, nil, providerObj, err 165 | } 166 | jobStatus.ProviderName = job.ProviderName 167 | return job, jobStatus, providerObj, nil 168 | } 169 | 170 | // swagger:route POST /jobs/{jobId}/cancel jobs cancelJob 171 | // 172 | // Creates a new transcoding job. 173 | // 174 | // Responses: 175 | // 200: jobStatus 176 | // 404: jobNotFound 177 | // 410: jobNotFoundInTheProvider 178 | // 500: genericError 179 | func (s *TranscodingService) cancelTranscodeJob(r *http.Request) swagger.GizmoJSONResponse { 180 | var params cancelTranscodeJobInput 181 | params.loadParams(server.Vars(r)) 182 | job, _, prov, err := s.getTranscodeJobByID(params.JobID) 183 | if err != nil { 184 | if err == db.ErrJobNotFound { 185 | return newJobNotFoundResponse(err) 186 | } 187 | if _, ok := err.(provider.JobNotFoundError); ok { 188 | return newJobNotFoundProviderResponse(err) 189 | } 190 | return swagger.NewErrorResponse(err) 191 | } 192 | err = prov.CancelJob(job.ProviderJobID) 193 | if err != nil { 194 | return swagger.NewErrorResponse(err) 195 | } 196 | status, err := prov.JobStatus(job) 197 | if err != nil { 198 | return swagger.NewErrorResponse(err) 199 | } 200 | status.ProviderName = job.ProviderName 201 | return newJobStatusResponse(status) 202 | } 203 | -------------------------------------------------------------------------------- /service/transcode_params.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | 8 | "github.com/video-dev/video-transcoding-api/v2/db" 9 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 10 | ) 11 | 12 | // NewTranscodeJobInputPayload makes up the parameters available for 13 | // specifying a new transcoding job 14 | type NewTranscodeJobInputPayload struct { 15 | // source media for the transcoding job. 16 | Source string `json:"source"` 17 | 18 | // list of outputs in this job 19 | Outputs []struct { 20 | FileName string `json:"fileName"` 21 | Preset string `json:"preset"` 22 | } `json:"outputs"` 23 | 24 | // provider to use in this job 25 | Provider string `json:"provider"` 26 | 27 | // provider Adaptive Streaming parameters 28 | StreamingParams db.StreamingParams `json:"streamingParams,omitempty"` 29 | } 30 | 31 | // swagger:parameters newJob 32 | type newTranscodeJobInput struct { 33 | // in: body 34 | // required: true 35 | Payload NewTranscodeJobInputPayload 36 | } 37 | 38 | // ProviderFactory loads and validates the parameters, and then returns the 39 | // provider factory. 40 | func (p *newTranscodeJobInput) ProviderFactory(body io.Reader) (provider.Factory, error) { 41 | err := p.loadParams(body) 42 | if err != nil { 43 | return nil, err 44 | } 45 | err = p.validate() 46 | if err != nil { 47 | return nil, err 48 | } 49 | return provider.GetProviderFactory(p.Payload.Provider) 50 | } 51 | 52 | func (p *newTranscodeJobInput) loadParams(body io.Reader) error { 53 | return json.NewDecoder(body).Decode(&p.Payload) 54 | } 55 | 56 | func (p *newTranscodeJobInput) validate() error { 57 | if p.Payload.Provider == "" { 58 | return errors.New("missing provider from request") 59 | } 60 | if p.Payload.Source == "" { 61 | return errors.New("missing source media from request") 62 | } 63 | if len(p.Payload.Outputs) == 0 { 64 | return errors.New("missing output list from request") 65 | } 66 | return nil 67 | } 68 | 69 | // swagger:parameters getJob 70 | type getTranscodeJobInput struct { 71 | // in: path 72 | // required: true 73 | JobID string `json:"jobId"` 74 | } 75 | 76 | func (p *getTranscodeJobInput) loadParams(paramsMap map[string]string) { 77 | p.JobID = paramsMap["jobId"] 78 | } 79 | 80 | // swagger:parameters cancelJob 81 | type cancelTranscodeJobInput struct { 82 | getTranscodeJobInput 83 | } 84 | -------------------------------------------------------------------------------- /service/transcode_responses.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/video-dev/video-transcoding-api/v2/internal/provider" 7 | "github.com/video-dev/video-transcoding-api/v2/swagger" 8 | ) 9 | 10 | // PartialJob is the simple response given to an API 11 | // call that creates a new transcoding job 12 | // swagger:model 13 | type PartialJob struct { 14 | // unique identifier of the job 15 | // 16 | // unique: true 17 | JobID string `json:"jobId"` 18 | } 19 | 20 | // JSON-encoded version of the Job, includes only the id of the job, that can 21 | // be used for querying the current status of the job. 22 | // 23 | // swagger:response job 24 | type jobResponse struct { 25 | // in: body 26 | Payload *PartialJob 27 | 28 | baseResponse 29 | } 30 | 31 | func newJobResponse(jobID string) *jobResponse { 32 | return &jobResponse{ 33 | baseResponse: baseResponse{ 34 | payload: &PartialJob{JobID: jobID}, 35 | status: http.StatusOK, 36 | }, 37 | } 38 | } 39 | 40 | // JSON-encoded JobStatus, containing status information given by the 41 | // underlying provider. 42 | // 43 | // swagger:response jobStatus 44 | type jobStatusResponse struct { 45 | // in: body 46 | Payload *provider.JobStatus 47 | 48 | baseResponse 49 | } 50 | 51 | func newJobStatusResponse(jobStatus *provider.JobStatus) *jobStatusResponse { 52 | return &jobStatusResponse{ 53 | baseResponse: baseResponse{ 54 | payload: jobStatus, 55 | status: http.StatusOK, 56 | }, 57 | } 58 | } 59 | 60 | // error returned when the given job data is not valid. 61 | // 62 | // swagger:response invalidJob 63 | type invalidJobResponse struct { 64 | // in: body 65 | Error *swagger.ErrorResponse 66 | } 67 | 68 | func newInvalidJobResponse(err error) *invalidJobResponse { 69 | return &invalidJobResponse{Error: swagger.NewErrorResponse(err).WithStatus(http.StatusBadRequest)} 70 | } 71 | 72 | func (r *invalidJobResponse) Result() (int, interface{}, error) { 73 | return r.Error.Result() 74 | } 75 | 76 | // error returned the given job id could not be found on the API. 77 | // 78 | // swagger:response jobNotFound 79 | type jobNotFoundResponse struct { 80 | // in: body 81 | Error *swagger.ErrorResponse 82 | } 83 | 84 | func newJobNotFoundResponse(err error) *jobNotFoundResponse { 85 | return &jobNotFoundResponse{Error: swagger.NewErrorResponse(err).WithStatus(http.StatusNotFound)} 86 | } 87 | 88 | func (r *jobNotFoundResponse) Result() (int, interface{}, error) { 89 | return r.Error.Result() 90 | } 91 | 92 | // error returned when the given job id could not be found on the underlying 93 | // provider. 94 | // 95 | // swagger:response jobNotFoundInTheProvider 96 | type jobNotFoundProviderResponse struct { 97 | // in: body 98 | Error *swagger.ErrorResponse 99 | } 100 | 101 | func newJobNotFoundProviderResponse(err error) *jobNotFoundProviderResponse { 102 | return &jobNotFoundProviderResponse{Error: swagger.NewErrorResponse(err).WithStatus(http.StatusGone)} 103 | } 104 | 105 | func (r *jobNotFoundProviderResponse) Result() (int, interface{}, error) { 106 | return r.Error.Result() 107 | } 108 | -------------------------------------------------------------------------------- /swagger/handler.go: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/NYTimes/gizmo/server" 7 | ) 8 | 9 | // GizmoJSONResponse represents a response type that can be converted to 10 | // Gizmo's JSONEndpoint result format. 11 | type GizmoJSONResponse interface { 12 | Result() (int, interface{}, error) 13 | } 14 | 15 | // Handler represents a function that receives an HTTP request and returns a 16 | // GizmoJSONResponse. It's a Gizmo JSONEndpoint structured for goswagger's 17 | // annotation. 18 | type Handler func(*http.Request) GizmoJSONResponse 19 | 20 | // HandlerToJSONEndpoint converts a handler to a proper Gizmo JSONEndpoint. 21 | func HandlerToJSONEndpoint(h Handler) server.JSONEndpoint { 22 | return func(r *http.Request) (int, interface{}, error) { 23 | return h(r).Result() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /swagger/handler_test.go: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestHandlerToJSONEndpoint(t *testing.T) { 10 | errResp := NewErrorResponse(errors.New("something went wrong")).WithStatus(http.StatusGone) 11 | handler := Handler(func(r *http.Request) GizmoJSONResponse { 12 | return errResp 13 | }) 14 | req, _ := http.NewRequest("GET", "/something", nil) 15 | code, data, err := HandlerToJSONEndpoint(handler)(req) 16 | if code != http.StatusGone { 17 | t.Errorf("wrong status code returned, want %d, got %d", http.StatusGone, code) 18 | } 19 | if data != nil { 20 | t.Errorf("unexpected non-nil data: %s", data) 21 | } 22 | if err != errResp { 23 | t.Errorf("wrong error returned\nwant %#v\ngot %#v", errResp, err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /swagger/response.go: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import "net/http" 4 | 5 | // ErrorResponse represents the basic error returned by the API on operation 6 | // failures. 7 | // 8 | // swagger:response genericError 9 | type ErrorResponse struct { 10 | // the error message 11 | // 12 | // in: body 13 | Message string `json:"error"` 14 | 15 | status int 16 | } 17 | 18 | // NewErrorResponse creates a new ErrorResponse with the given error. The 19 | // default status code for error responses is 500 (InternalServerError). Use 20 | // the method WithError to customize it. 21 | func NewErrorResponse(err error) *ErrorResponse { 22 | errResp := &ErrorResponse{ 23 | Message: err.Error(), 24 | status: http.StatusInternalServerError, 25 | } 26 | return errResp 27 | } 28 | 29 | // WithStatus creates a new copy of ErrorResponse using the given status. 30 | func (r *ErrorResponse) WithStatus(status int) *ErrorResponse { 31 | if status > 0 { 32 | return &ErrorResponse{Message: r.Message, status: status} 33 | } 34 | return r 35 | } 36 | 37 | // Error returns the underlying error message. 38 | func (r *ErrorResponse) Error() string { 39 | return r.Message 40 | } 41 | 42 | // Result ensures that ErrorResponse implements the interface Handler. 43 | func (r *ErrorResponse) Result() (int, interface{}, error) { 44 | return r.status, nil, r 45 | } 46 | -------------------------------------------------------------------------------- /swagger/response_test.go: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "testing" 8 | ) 9 | 10 | func TestErrorResponse(t *testing.T) { 11 | internalError := errors.New("something went wrong") 12 | errResp := NewErrorResponse(internalError) 13 | code, data, err := errResp.Result() 14 | if code != http.StatusInternalServerError { 15 | t.Errorf("Wrong error code. Want %d. Got %d", http.StatusInternalServerError, code) 16 | } 17 | if data != nil { 18 | t.Errorf("Unexpected non-nil data: %#v", data) 19 | } 20 | if err != errResp { 21 | t.Errorf("Wrong error returned. Want %#v. Got %#v", errResp, err) 22 | } 23 | } 24 | 25 | func TestErrorResponseCustomStatus(t *testing.T) { 26 | internalError := errors.New("something went wrong") 27 | errResp := NewErrorResponse(internalError).WithStatus(http.StatusBadRequest) 28 | code, data, err := errResp.Result() 29 | if code != http.StatusBadRequest { 30 | t.Errorf("Wrong error code. Want %d. Got %d", http.StatusBadRequest, code) 31 | } 32 | if data != nil { 33 | t.Errorf("Unexpected non-nil data: %#v", data) 34 | } 35 | if err != errResp { 36 | t.Errorf("Wrong error returned. Want %#v. Got %#v", errResp, err) 37 | } 38 | } 39 | 40 | func TestErrorResponseZeroStatus(t *testing.T) { 41 | internalError := errors.New("something went wrong") 42 | errResp := NewErrorResponse(internalError).WithStatus(0) 43 | code, data, err := errResp.Result() 44 | if code != http.StatusInternalServerError { 45 | t.Errorf("Wrong error code. Want %d. Got %d", http.StatusInternalServerError, code) 46 | } 47 | if data != nil { 48 | t.Errorf("Unexpected non-nil data: %#v", data) 49 | } 50 | if err != errResp { 51 | t.Errorf("Wrong error returned. Want %#v. Got %#v", errResp, err) 52 | } 53 | } 54 | 55 | func TestErrorResponsErrorInterface(t *testing.T) { 56 | var err error 57 | msg := "something went wrong" 58 | err = &ErrorResponse{Message: msg} 59 | if err.Error() != msg { 60 | t.Errorf("Got wrong error message. Want %q. Got %q", msg, err.Error()) 61 | } 62 | } 63 | 64 | func TestJSONMarshalling(t *testing.T) { 65 | err := NewErrorResponse(errors.New("something went wrong")) 66 | expected := `{"error":"something went wrong"}` 67 | got, jErr := json.Marshal(err) 68 | if jErr != nil { 69 | t.Fatal(err) 70 | } 71 | if string(got) != expected { 72 | t.Errorf("Wrong json marshalled. Want %q. Got %q", expected, string(got)) 73 | } 74 | } 75 | --------------------------------------------------------------------------------