diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 0000000..fd3c07f --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,40 @@ +name: Publish docker image + +on: + release: + types: [published] + +jobs: + push_to_registry: + name: Push Docker Image to Docker Hub + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.HUB_DOCKER_USERNAME }} + password: ${{ secrets.HUB_DOCKER_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: eyevinntechnology/live-encoding + + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 68cc37b..72ceb7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,18 @@ ARG NODE_IMAGE=node:18-alpine FROM ${NODE_IMAGE} ENV NODE_ENV=production EXPOSE 8000 +RUN apk update +RUN apk add --no-cache ffmpeg RUN mkdir /app RUN chown node:node /app +RUN mkdir /data && chown node:node /data USER node WORKDIR /app +VOLUME [ "/data" ] COPY --chown=node:node ["package.json", "package-lock.json*", "tsconfig*.json", "./"] COPY --chown=node:node ["src", "./src"] # Delete prepare script to avoid errors from husky RUN npm pkg delete scripts.prepare \ && npm ci --omit=dev +ENV ORIGIN_DIR=/data CMD [ "npm", "run", "start" ] diff --git a/jest.config.js b/jest.config.js index 91a2d2c..eef6b07 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,4 @@ module.exports = { preset: 'ts-jest', - testEnvironment: 'node', -}; \ No newline at end of file + testEnvironment: 'node' +}; diff --git a/nodemon.json b/nodemon.json index c5add9f..d734304 100644 --- a/nodemon.json +++ b/nodemon.json @@ -2,4 +2,4 @@ "watch": ["src"], "ext": "ts", "exec": "node --inspect -r ts-node/register ./src/server.ts" -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index dd928ba..33d9132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "license": "MIT", "dependencies": { "@fastify/cors": "^8.2.0", + "@fastify/static": "^7.0.4", "@fastify/swagger": "^8.3.1", "@fastify/swagger-ui": "^1.5.0", "@fastify/type-provider-typebox": "^3.6.0", "@sinclair/typebox": "^0.29.0", + "chalk": "4.1.2", "fastify": "4.23.2", "nodemon": "^2.0.20", "ts-node": "^10.9.1" @@ -1045,16 +1047,16 @@ } }, "node_modules/@fastify/static": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-6.12.0.tgz", - "integrity": "sha512-KK1B84E6QD/FcQWxDI2aiUCwHxMJBI1KeCUzm1BwYpPY1b742+jeKruGHP2uOluuM6OkBPI8CIANrXcCRtC2oQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz", + "integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==", "dependencies": { "@fastify/accept-negotiator": "^1.0.0", "@fastify/send": "^2.0.0", "content-disposition": "^0.5.3", "fastify-plugin": "^4.0.0", - "glob": "^8.0.1", - "p-limit": "^3.1.0" + "fastq": "^1.17.0", + "glob": "^10.3.4" } }, "node_modules/@fastify/swagger": { @@ -1081,6 +1083,57 @@ "yaml": "^2.2.2" } }, + "node_modules/@fastify/swagger-ui/node_modules/@fastify/static": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-6.12.0.tgz", + "integrity": "sha512-KK1B84E6QD/FcQWxDI2aiUCwHxMJBI1KeCUzm1BwYpPY1b742+jeKruGHP2uOluuM6OkBPI8CIANrXcCRtC2oQ==", + "dependencies": { + "@fastify/accept-negotiator": "^1.0.0", + "@fastify/send": "^2.0.0", + "content-disposition": "^0.5.3", + "fastify-plugin": "^4.0.0", + "glob": "^8.0.1", + "p-limit": "^3.1.0" + } + }, + "node_modules/@fastify/swagger-ui/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@fastify/swagger-ui/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/swagger-ui/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@fastify/type-provider-typebox": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-3.6.0.tgz", @@ -1124,6 +1177,95 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1617,6 +1759,15 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@sinclair/typebox": { "version": "0.29.6", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.29.6.tgz", @@ -2100,7 +2251,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2109,7 +2259,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2502,7 +2651,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2612,7 +2760,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2623,8 +2770,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/compare-func": { "version": "2.0.0", @@ -2779,7 +2925,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2947,6 +3092,11 @@ "node": ">=8" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -2983,8 +3133,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/error-ex": { "version": "1.3.2", @@ -3594,6 +3743,32 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/foreground-child": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3702,19 +3877,19 @@ } }, "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">=12" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3741,14 +3916,17 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/global-dirs": { @@ -3823,7 +4001,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -4083,7 +4260,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -4170,8 +4346,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -4239,6 +4414,23 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.2.tgz", + "integrity": "sha512-qH3nOSj8q/8+Eg8LUPOq3C+6HWkpUioIjDsq1+D4zY91oZvpPttw8GwtF1nReRYKXl+1AORyFqtm2f5Q1SB6/Q==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "14 >=14.21 || 16 >=16.20 || >=18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.1.tgz", @@ -5315,6 +5507,14 @@ "node": ">= 6" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mnemonist": { "version": "0.39.6", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", @@ -5545,6 +5745,11 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5597,7 +5802,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -5608,6 +5812,26 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -6342,7 +6566,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6354,7 +6577,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -6541,7 +6763,20 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -6555,7 +6790,18 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -6609,7 +6855,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7045,7 +7290,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -7082,6 +7326,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 929b24d..923af28 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,12 @@ }, "dependencies": { "@fastify/cors": "^8.2.0", + "@fastify/static": "^7.0.4", "@fastify/swagger": "^8.3.1", "@fastify/swagger-ui": "^1.5.0", "@fastify/type-provider-typebox": "^3.6.0", "@sinclair/typebox": "^0.29.0", + "chalk": "4.1.2", "fastify": "4.23.2", "nodemon": "^2.0.20", "ts-node": "^10.9.1" diff --git a/readme-typescript-nodejs.md b/readme-typescript-nodejs.md index eba2b20..17fd27a 100644 --- a/readme-typescript-nodejs.md +++ b/readme-typescript-nodejs.md @@ -1,4 +1,4 @@ -# typescript-nodejs +# typescript-nodejs Requirements: node.js >= 18.15.0 ( LTS ) @@ -14,4 +14,4 @@ Template for TypeScript nodejs projects. It includes: 1. Update package.json with your project details 2. Install dependencies with `npm install` -3. Start coding! \ No newline at end of file +3. Start coding! diff --git a/readme.md b/readme.md index 4a57025..83c9975 100644 --- a/readme.md +++ b/readme.md @@ -3,39 +3,99 @@
- Open Source Live Encoder based on ffmpeg. + Open Source Live Encoder based on ffmpeg and Shaka packager.

- :book: Read the documentation (github pages) :eyes: + :book: Available as a Service :eyes:

-[![npm](https://img.shields.io/npm/v/@eyevinn/{{repo-name}}?style=flat-square)](https://www.npmjs.com/package/@eyevinn/{{repo-name}}) -[![github release](https://img.shields.io/github/v/release/Eyevinn/{{repo-name}}?style=flat-square)](https://github.com/Eyevinn/{{repo-name}}/releases) -[![license](https://img.shields.io/github/license/eyevinn/{{repo-name}}.svg?style=flat-square)](LICENSE) - -[![PRs welcome](https://img.shields.io/badge/PRs-welcome-ff69b4.svg?style=flat-square)](https://github.com/eyevinn/{{repo-name}}/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) -[![made with hearth by Eyevinn](https://img.shields.io/badge/made%20with%20%E2%99%A5%20by-Eyevinn-59cbe8.svg?style=flat-square)](https://github.com/eyevinn) +[![PRs welcome](https://img.shields.io/badge/PRs-welcome-ff69b4.svg?style=flat-square)](https://github.com/Eyevinn/live-encoding/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) +[![made with hearth by Eyevinn](https://img.shields.io/badge/made%20with%20%E2%99%A5%20by-Eyevinn-59cbe8.svg?style=flat-square)](https://github.com/Eyevinn) [![Slack](http://slack.streamingtech.se/badge.svg)](http://slack.streamingtech.se)
- +Live transcoding to HLS and optionally MPEG-DASH. Provides origin for CDN shield to pull streams as well as push to CDN origin. ## Requirements - +- ffmpeg and optionally Shaka packager installed ## Installation / Usage - +``` +% npm install +``` + +### Environment Variables + +| Variable | Description | Default value | +| ------------ | ------------------------------------------------------------------------------ | ------------- | +| `PORT` | API port to bind and listen to | `8000` | +| `ORIGIN_DIR` |  Location on disk where to write media segments and playlists | `/tmp/media` | +| `HLS_ONLY` | Only output HLS + TS | `true` | +| `RTMP_PORT` | RTMP port to bind and listen to | `1935` | +| `STREAM_KEY` | RTMP streamkey | `stream` | +| `OUTPUT_URL` | URL to upload media segments and playlists. If not set push to CDN is disabled | | + +Run encoder with media dir at `/data` + +``` +% ORIGIN_DIR=/data npm start +``` + +Start encoder: + +``` +% curl -X 'POST' \ + 'http://localhost:8000/encoder' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "timeout": 0 +}' +``` + +Get status: + +``` +% curl -X 'GET' \ + 'http://localhost:8000/encoder' \ + -H 'accept: application/json' +``` + +If status is `starting` you can start pushing to the RTMP address `rtmp://:1935/live/stream` (where `stream` is the streamkey). + +When status is `running` you can play the HLS from `http://localhost:8000/origin/hls/index.m3u8` + +Top stop the encoder: + +``` +% curl -X 'DELETE' \ + 'http://localhost:8000/encoder' \ + -H 'accept: application/json' +``` + +### Docker + +Run Eyevinn live encoding as a Docker container where `/tmp/media` is a directory on your host. + +``` +% docker run --rm -d \ + -p 8000:8000 -p 1935:1935 \ + -v /tmp/media:/data \ + eyevinntechnology/live-encoding +``` ## Development - +``` +% DEBUG=1 npm start +``` ## Contributing diff --git a/src/api.ts b/src/api.ts index 9f5e143..0c6a0cf 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,7 +14,11 @@ export interface HealthcheckOptions { title: string; } -const healthcheck: FastifyPluginCallback = (fastify, opts, next) => { +const healthcheck: FastifyPluginCallback = ( + fastify, + opts, + next +) => { fastify.get<{ Reply: Static }>( '/', { @@ -49,17 +53,16 @@ export default (opts: ApiOptions) => { swagger: { info: { title: opts.title, - description: 'hello', + description: 'Live Encoder Management API', version: 'v1' } } }); api.register(swaggerUI, { - routePrefix: '/docs' + routePrefix: '/api/docs' }); api.register(healthcheck, { title: opts.title }); - // register other API routes here return api; -} +}; diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 0000000..d6209cf --- /dev/null +++ b/src/api/errors.ts @@ -0,0 +1,30 @@ +import { + FastifyReply, + RawReplyDefaultExpression, + RawRequestDefaultExpression, + RawServerDefault +} from 'fastify'; +import { Static, Type } from '@sinclair/typebox'; +import { InvalidInputError, NotFoundError } from '../utils/error'; + +export const ErrorResponse = Type.Object({ + reason: Type.String({ description: 'Reason why something failed' }) +}); +export type ErrorResponse = Static; + +export type ErrorReply = FastifyReply< + RawServerDefault, + RawRequestDefaultExpression, + RawReplyDefaultExpression, + { Reply: ErrorResponse } +>; + +export const errorReply = (reply: ErrorReply, err: unknown) => { + if (err instanceof NotFoundError) { + reply.code(404).send({ reason: err.message }); + } else if (err instanceof InvalidInputError) { + reply.code(400).send({ reason: err.message }); + } else { + reply.code(500).send({ reason: 'Unhandled error: ' + err }); + } +}; diff --git a/src/encoder.test.ts b/src/encoder.test.ts new file mode 100644 index 0000000..49f74f2 --- /dev/null +++ b/src/encoder.test.ts @@ -0,0 +1,124 @@ +import { + BitrateLadderStep, + generateFilterComplex, + generateOutput +} from './encoder'; + +const testLadder: BitrateLadderStep[] = [ + { + mediaType: 'video', + bitrate: '6M', + video: { width: 1280, height: 720 } + }, + { + mediaType: 'video', + bitrate: '3M', + video: { width: 640, height: 360 } + }, + { + mediaType: 'audio', + bitrate: '128k', + audio: { channels: 2, sampleRate: 48000 } + } +]; + +describe('encoder util', () => { + test('can generate filter-complex args', () => { + const filterComplex = generateFilterComplex(testLadder); + expect(filterComplex).toEqual([ + '-filter_complex', + '[0:v]split=2[v1][v2];[v1]scale=w=1280:h=720[v1out];[v2]scale=w=640:h=360[v2out]', + + '-map', + '[v1out]', + '-c:v:0', + 'libx264', + '-x264-params', + 'nal-hrd=cbr:force-cfr=1', + '-b:v:0', + '6M', + '-maxrate:v:0', + '6M', + '-minrate:v:0', + '6M', + '-bufsize:v:0', + '6M', + '-preset', + 'ultrafast', + '-g', + '48', + '-sc_threshold', + '0', + '-keyint_min', + '48', + + '-map', + '[v2out]', + '-c:v:1', + 'libx264', + '-x264-params', + 'nal-hrd=cbr:force-cfr=1', + '-b:v:1', + '3M', + '-maxrate:v:1', + '3M', + '-minrate:v:1', + '3M', + '-bufsize:v:1', + '3M', + '-preset', + 'ultrafast', + '-g', + '48', + '-sc_threshold', + '0', + '-keyint_min', + '48', + + '-map', + 'a:0', + '-c:a:0', + 'aac', + '-b:a:0', + '128k', + '-ar', + '48000', + '-ac', + '2', + + '-map', + 'a:0', + '-c:a:1', + 'aac', + '-b:a:1', + '128k', + '-ar', + '48000', + '-ac', + '2' + ]); + }); + + test('can generate outout args', () => { + const hlsOnly = generateOutput(true, testLadder, '/data'); + expect(hlsOnly).toEqual([ + '-f', + 'hls', + '-hls_time', + '10', + '-hls_flags', + 'independent_segments+delete_segments', + '-hls_segment_type', + 'mpegts', + '-hls_segment_filename', + '/data/hls/master_%v_%02d.ts', + '-hls_list_size', + '6', + '-master_pl_name', + 'index.m3u8', + '-var_stream_map', + 'v:0,a:0 v:1,a:1', + '/data/hls/master_%v.m3u8' + ]); + }); +}); diff --git a/src/encoder.ts b/src/encoder.ts new file mode 100644 index 0000000..a6c6e25 --- /dev/null +++ b/src/encoder.ts @@ -0,0 +1,319 @@ +import { ChildProcess, spawn } from 'child_process'; +import { + EncoderStartRequest, + EncoderStartResponse, + EncoderStatus +} from './model'; +import { Log } from './utils/log'; +import path from 'path'; +import { access, constants, mkdir, rm } from 'fs/promises'; +import { existsSync } from 'fs'; + +export type BitrateLadderStep = { + mediaType: 'video' | 'audio'; + bitrate: string; + video?: { + width: number; + height: number; + }; + audio?: { + channels: number; + sampleRate: number; + }; +}; + +const DEFAULT_LADDER: BitrateLadderStep[] = [ + { + mediaType: 'video', + bitrate: '4M', + video: { width: 1280, height: 720 } + }, + { + mediaType: 'video', + bitrate: '3M', + video: { width: 640, height: 360 } + }, + { + mediaType: 'audio', + bitrate: '128k', + audio: { channels: 2, sampleRate: 48000 } + } +]; + +export type EncoderOpts = { + hlsOnly: boolean; + outputUrl?: URL; +}; + +type Process = { + exitCode: number; + process?: ChildProcess; +}; + +export class Encoder { + private status: EncoderStatus = 'idle'; + private wantsToStop = false; + private ffmpeg?: Process; + + constructor( + private ffmpegExecutable: string, + private shakaPackagerExecutable: string, + private rtmpPort: number, + private streamKey: string, + private mediaDir: string, + private opts: EncoderOpts + ) {} + + public async start( + startRequest: EncoderStartRequest + ): Promise { + const filterComplexArgs = generateFilterComplex(DEFAULT_LADDER); + const inputArgs = generateInput(this.rtmpPort, this.streamKey); + const outputArgs = generateOutput( + this.opts.hlsOnly, + DEFAULT_LADDER, + this.mediaDir + ); + const ffmpegArgs = inputArgs.concat(filterComplexArgs).concat(outputArgs); + + this.status = 'starting'; + const startAttemptTs = Date.now(); + const monitor = setInterval(async () => { + if (this.ffmpeg) { + if (!this.ffmpeg.process) { + if (!this.wantsToStop) { + Log().info( + 'ffmpeg process unintentionally exited with code ' + + this.ffmpeg.exitCode + ); + this.status = 'error'; + } else { + Log().info( + 'ffmpeg process intentionally exited with code ' + + this.ffmpeg.exitCode + ); + this.status = 'stopped'; + } + clearInterval(monitor); + } else { + if (await this.hlsIndexIsAvailable()) { + Log().info( + 'We have HLS index file available, change status to running' + ); + this.status = 'running'; + } + if (startRequest.timeout) { + if ( + this.status != 'running' && + Date.now() - startAttemptTs > startRequest.timeout * 1000 + ) { + Log().info('Timeout reached'); + await this.stop(); + this.status = 'stopped'; + clearInterval(monitor); + } + } + } + } + }, 5000); + await this.startFFmpeg(ffmpegArgs); + + return { + rtmpPort: this.rtmpPort, + streamKey: this.streamKey, + outputUrl: this.opts.outputUrl?.toString(), + playlist: '/origin/index.m3u8', + status: this.status + }; + } + + public async stop() { + this.wantsToStop = true; + await this.stopFFmpeg(); + } + + public async getStatus(): Promise { + return this.status; + } + + public getOriginPlaylist(): string | undefined { + return this.status === 'running' ? '/origin/hls/index.m3u8' : undefined; + } + + private async hlsIndexIsAvailable(): Promise { + const file = path.join(this.mediaDir, '/hls/index.m3u8'); + try { + await access(file, constants.F_OK); + return true; + } catch (err) { + Log().debug(err); + return false; + } + } + + private async startFFmpeg(ffmpegArgs: string[]) { + if (!existsSync(path.join(this.mediaDir, '/hls'))) { + await mkdir(path.join(this.mediaDir, '/hls'), { recursive: true }); + } + this.wantsToStop = false; + this.ffmpeg = { + exitCode: 0, + process: spawn(this.ffmpegExecutable, ffmpegArgs) + }; + this.ffmpeg.process?.stdout?.on('data', (data) => + Log().debug(data.toString()) + ); + this.ffmpeg.process?.stderr?.on('data', (data) => + Log().debug(data.toString()) + ); + this.ffmpeg.process?.on('exit', (code) => { + Log().debug('ffmpeg exited with code ' + code); + Log().debug(this.ffmpeg?.process?.spawnargs); + Log().debug(`wantsToStop: ${this.wantsToStop}`); + if (this.ffmpeg) { + this.ffmpeg.process = undefined; + this.ffmpeg.exitCode = code || this.wantsToStop ? 0 : 1; + } + }); + } + + private async stopFFmpeg() { + const waitForKilled = new Promise((resolve) => { + const t = setInterval(() => { + if (!this.ffmpeg?.process) { + clearInterval(t); + resolve(); + } + }, 1000); + }); + if (this.ffmpeg?.process) { + this.wantsToStop = true; + this.ffmpeg.process.kill('SIGKILL'); + await waitForKilled; + await this.cleanup(); + } + } + + private async cleanup() { + try { + Log().debug('Cleaning up HLS files'); + await rm(path.join(this.mediaDir, '/hls'), { recursive: true }); + } catch (err) { + Log().debug(err); + } + } +} + +export function generateFilterComplex(ladder: BitrateLadderStep[]): string[] { + const videos = ladder.filter( + (step) => step.mediaType === 'video' && step.video + ); + let filterComplexString = `[0:v]split=${videos.length}`; + for (let i = 0; i < videos.length; i++) { + filterComplexString += `[v${i + 1}]`; + } + filterComplexString += ';'; + for (let i = 0; i < videos.length; i++) { + filterComplexString += `[v${i + 1}]scale=w=${videos[i].video?.width}:h=${ + videos[i].video?.height + }[v${i + 1}out]`; + if (i < videos.length - 1) { + filterComplexString += ';'; + } + } + const videoMaps = []; + const audioMaps = []; + const audio = ladder.find((step) => step.mediaType === 'audio' && step.audio); + for (let i = 0; i < videos.length; i++) { + const videoMap = [ + '-map', + `[v${i + 1}out]`, + `-c:v:${i}`, + 'libx264', + '-x264-params', + 'nal-hrd=cbr:force-cfr=1', + `-b:v:${i}`, + videos[i].bitrate, + `-maxrate:v:${i}`, + videos[i].bitrate, + `-minrate:v:${i}`, + videos[i].bitrate, + `-bufsize:v:${i}`, + videos[i].bitrate, + '-preset', + 'ultrafast', + '-g', + '48', + '-sc_threshold', + '0', + '-keyint_min', + '48' + ]; + videoMaps.push(videoMap); + if (audio) { + const audioMap = [ + '-map', + `a:0`, + `-c:a:${i}`, + 'aac', + `-b:a:${i}`, + audio.bitrate, + '-ar', + audio.audio?.sampleRate.toString() || '', + '-ac', + audio.audio?.channels.toString() || '' + ]; + audioMaps.push(audioMap); + } + } + + return ['-filter_complex', filterComplexString] + .concat(videoMaps.flat()) + .concat(audioMaps.flat()); +} + +export function generateInput(rtmpPort: number, streamKey: string): string[] { + return [ + '-y', + '-listen', + '1', + '-i', + `rtmp://0.0.0.0:${rtmpPort}/live/${streamKey}` + ]; +} +export function generateOutput( + hlsOnly: boolean, + ladder: BitrateLadderStep[], + mediaDir: string +): string[] { + if (hlsOnly) { + let varStreamMap = ''; + const videos = ladder.filter((step) => step.mediaType === 'video'); + for (let i = 0; i < videos.length; i++) { + varStreamMap += `v:${i},a:${i}`; + if (i < videos.length - 1) { + varStreamMap += ' '; + } + } + return [ + '-f', + 'hls', + '-hls_time', + '10', + '-hls_flags', + 'independent_segments+delete_segments', + '-hls_segment_type', + 'mpegts', + '-hls_segment_filename', + `${mediaDir}/hls/media_%v_%02d.ts`, + '-hls_list_size', + '6', + '-master_pl_name', + 'index.m3u8', + '-var_stream_map', + varStreamMap, + `${mediaDir}/hls/media_%v.m3u8` + ]; + } + return []; +} diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..23c8a2f --- /dev/null +++ b/src/model.ts @@ -0,0 +1,37 @@ +import { Static, Type } from '@sinclair/typebox'; + +const StringEnum = (values: [...T]) => + Type.Unsafe({ + type: 'string', + enum: values + }); + +export const EncoderStatus = StringEnum([ + 'idle', + 'starting', + 'running', + 'stopped', + 'error' +]); +export type EncoderStatus = Static; +export const EncoderStatusResponse = Type.Object({ + status: EncoderStatus, + playlist: Type.Optional( + Type.String({ description: 'Origin playlist location' }) + ) +}); +export type EncoderStatusResponse = Static; + +export const EncoderStartRequest = Type.Object({ + timeout: Type.Optional(Type.Number({ description: 'Timeout in seconds' })) +}); +export type EncoderStartRequest = Static; + +export const EncoderStartResponse = Type.Object({ + rtmpPort: Type.Number({ description: 'RTMP port' }), + streamKey: Type.String({ description: 'Stream key' }), + outputUrl: Type.Optional(Type.String({ description: 'Output URL' })), + playlist: Type.String({ description: 'Origin playlist location' }), + status: EncoderStatus +}); +export type EncoderStartResponse = Static; diff --git a/src/routes/encoder.ts b/src/routes/encoder.ts new file mode 100644 index 0000000..79332f1 --- /dev/null +++ b/src/routes/encoder.ts @@ -0,0 +1,100 @@ +import { FastifyPluginCallback } from 'fastify'; +import { Encoder } from '../encoder'; +import { ErrorReply, ErrorResponse, errorReply } from '../api/errors'; +import { + EncoderStartRequest, + EncoderStartResponse, + EncoderStatusResponse +} from '../model'; + +export interface RouteEncoderOpts { + encoder: Encoder; +} + +const encoder: FastifyPluginCallback = ( + fastify, + opts, + next +) => { + const encoder = opts.encoder; + + fastify.setErrorHandler((error, request, reply) => { + reply.code(500).send({ reason: error.message }); + }); + + fastify.get<{ + Reply: EncoderStatusResponse | ErrorResponse; + }>( + '/encoder', + { + schema: { + description: 'Get encoder status', + response: { + 200: EncoderStatusResponse, + 500: ErrorResponse + } + } + }, + async (request, reply) => { + try { + const status = await encoder.getStatus(); + reply.send({ status, playlist: encoder.getOriginPlaylist() }); + } catch (err) { + errorReply(reply as ErrorReply, err); + } + } + ); + + fastify.post<{ + Body: EncoderStartRequest; + Reply: EncoderStartResponse | ErrorResponse; + }>( + '/encoder', + { + schema: { + description: 'Start encoder', + body: EncoderStartRequest, + response: { + 200: EncoderStartResponse, + 500: ErrorResponse + } + } + }, + async (request, reply) => { + try { + const encoding = await encoder.start(request.body); + reply.send(encoding); + } catch (err) { + errorReply(reply as ErrorReply, err); + } + } + ); + + fastify.delete<{ + Reply: EncoderStatusResponse | ErrorResponse; + }>( + '/encoder', + { + schema: { + description: 'Stop encoder', + response: { + 200: EncoderStatusResponse, + 500: ErrorResponse + } + } + }, + async (request, reply) => { + try { + await encoder.stop(); + const status = await encoder.getStatus(); + reply.send({ status, playlist: encoder.getOriginPlaylist() }); + } catch (err) { + errorReply(reply as ErrorReply, err); + } + } + ); + + next(); +}; + +export default encoder; diff --git a/src/routes/origin.ts b/src/routes/origin.ts new file mode 100644 index 0000000..12da98f --- /dev/null +++ b/src/routes/origin.ts @@ -0,0 +1,21 @@ +import { FastifyPluginCallback } from 'fastify'; +import fastifyStatic from '@fastify/static'; + +export interface RouteOriginOpts { + mediaPath: string; +} + +const origin: FastifyPluginCallback = ( + fastify, + opts, + next +) => { + fastify.register(fastifyStatic, { + root: opts.mediaPath, + prefix: '/origin' + }); + + next(); +}; + +export default origin; diff --git a/src/server.ts b/src/server.ts index bf9a471..521b263 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,41 @@ import api from './api'; +import { Encoder } from './encoder'; +import routeEncoder from './routes/encoder'; +import routeOrigin from './routes/origin'; +import { Log } from './utils/log'; -const server = api({ title: '@eyevinn/typescript-nodejs' }); +const server = api({ title: 'Eyevinn Live Encoding' }); -const PORT = process.env.PORT ? Number(process.env.PORT) : 8000; +const mediaDir = process.env.ORIGIN_DIR || '/tmp/media'; + +const encoderOpts = { + hlsOnly: process.env.HLS_ONLY + ? process.env.HLS_ONLY.toLowerCase() === 'true' || + process.env.HLS_ONLY === '1' + : true, + outputUrl: process.env.OUTPUT_URL + ? new URL(process.env.OUTPUT_URL) + : undefined +}; +const encoder = new Encoder( + process.env.FFMPEG_EXECUTABLE || 'ffmpeg', + process.env.SHAKA_PACKAGER_EXECUTABLE || 'packager', + process.env.RTMP_PORT ? parseInt(process.env.RTMP_PORT, 10) : 1935, + process.env.STREAM_KEY || 'stream', + mediaDir, + encoderOpts +); +server.register(routeEncoder, { encoder }); +server.register(routeOrigin, { + mediaPath: mediaDir +}); +const PORT = process.env.PORT ? Number(process.env.PORT) : 8000; server.listen({ port: PORT, host: '0.0.0.0' }, (err, address) => { if (err) { throw err; } - console.log(`Server listening on ${address}`); + Log().info(`Server listening on ${address}`); }); export default server; diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..676d393 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,11 @@ +export class NotFoundError extends Error { + constructor({ id }: { id: string }) { + super(`Resource with id '${id}' not found`); + } +} + +export class InvalidInputError extends Error { + constructor({ reason }: { reason: string }) { + super(reason); + } +} diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 0000000..b3f8c59 --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,101 @@ +import chalk, { ForegroundColor } from 'chalk'; +import util from 'util'; + +let logger_: Logger | undefined; + +type LevelCode = 'info' | 'error' | 'warn' | 'debug' | 'fatal'; +type LevelItem = { + text: string; + method: LevelCode; + color: typeof ForegroundColor; +}; +const LEVELS: { [k in LevelCode]: LevelItem } = { + info: { text: 'info', method: 'info', color: 'white' }, + error: { text: 'error', method: 'error', color: 'red' }, + warn: { text: 'warn', method: 'info', color: 'yellow' }, + debug: { text: 'debug', method: 'info', color: 'blue' }, + fatal: { text: 'fatal', method: 'error', color: 'redBright' } +}; + +function log(method: LevelCode) { + switch (method) { + case 'info': + return console.info; + case 'error': + return console.error; + default: + throw new Error(`Invalid log method ${method}`); + } +} + +function logfmt(levelCode: LevelCode, ...args: any[]) { + const level = LEVELS[levelCode]; + if (levelCode == 'debug') { + log(level.method)(...args); + } else { + const msg = util.format(...args); + log(level.method)(chalk[level.color](msg)); + } +} + +export class Logger { + private debugMode = false; + + constructor() { + return this; + } + + info(...args: any[]) { + logfmt('info', ...args); + return this; + } + + warn(...args: any[]) { + logfmt('warn', ...args); + return this; + } + + error(...args: any[]) { + logfmt('error', ...args); + return this; + } + + debug(...args: any[]) { + if (this.debugMode) logfmt('debug', ...args); + return this; + } + + fatal(...args: any[]) { + logfmt('fatal', ...args); + return this; + } + + get level(): 'debug' | 'info' { + return this.debugMode ? 'debug' : 'info'; + } + + set level(value: 'debug' | 'info') { + if (value == 'debug') { + this.debugMode = true; + } else if (value == 'info') { + this.debugMode = false; + } else { + this.warn('level', value, 'not supported'); + } + } +} + +export function Log(): Logger { + if (logger_) { + return logger_; + } + + logger_ = new Logger(); + logger_.level = 'info'; + + if (process.env.DEBUG) { + logger_.level = 'debug'; + } + + return logger_; +}