Browse Source

Migrate server to ESM

Sorry for the very big commit that may lead to git log issues and merge
conflicts, but it's a major step forward:

 * Server can be faster at startup because imports() are async and we can
   easily lazy import big modules
 * Angular doesn't seem to support ES import (with .js extension), so we
   had to correctly organize peertube into a monorepo:
    * Use yarn workspace feature
    * Use typescript reference projects for dependencies
    * Shared projects have been moved into "packages", each one is now a
      node module (with a dedicated package.json/tsconfig.json)
    * server/tools have been moved into apps/ and is now a dedicated app
      bundled and published on NPM so users don't have to build peertube
      cli tools manually
    * server/tests have been moved into packages/ so we don't compile
      them every time we want to run the server
 * Use isolatedModule option:
   * Had to move from const enum to const
     (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums)
   * Had to explictely specify "type" imports when used in decorators
 * Prefer tsx (that uses esbuild under the hood) instead of ts-node to
   load typescript files (tests with mocha or scripts):
     * To reduce test complexity as esbuild doesn't support decorator
       metadata, we only test server files that do not import server
       models
     * We still build tests files into js files for a faster CI
 * Remove unmaintained peertube CLI import script
 * Removed some barrels to speed up execution (less imports)
Chocobozzz 9 months ago
parent
commit
3a4992633e
100 changed files with 3437 additions and 111 deletions
  1. 13 10
      .eslintrc.json
  2. 17 14
      .github/CONTRIBUTING.md
  3. 9 1
      .github/actions/reusable-prepare-peertube-build/action.yml
  4. 1 1
      .github/workflows/benchmark.yml
  5. 1 1
      .github/workflows/codeql/codeql-config.yml
  6. 2 2
      .github/workflows/stats.yml
  7. 9 7
      .gitignore
  8. 10 0
      .mocharc.cjs
  9. 4 0
      apps/peertube-cli/.npmignore
  10. 43 0
      apps/peertube-cli/README.md
  11. 19 0
      apps/peertube-cli/package.json
  12. 27 0
      apps/peertube-cli/scripts/build.js
  13. 7 0
      apps/peertube-cli/scripts/watch.js
  14. 171 0
      apps/peertube-cli/src/peertube-auth.ts
  15. 39 0
      apps/peertube-cli/src/peertube-get-access-token.ts
  16. 167 0
      apps/peertube-cli/src/peertube-plugins.ts
  17. 186 0
      apps/peertube-cli/src/peertube-redundancy.ts
  18. 167 0
      apps/peertube-cli/src/peertube-upload.ts
  19. 64 0
      apps/peertube-cli/src/peertube.ts
  20. 195 0
      apps/peertube-cli/src/shared/cli.ts
  21. 1 0
      apps/peertube-cli/src/shared/index.ts
  22. 15 0
      apps/peertube-cli/tsconfig.json
  23. 374 0
      apps/peertube-cli/yarn.lock
  24. 0 0
      apps/peertube-runner/.gitignore
  25. 4 0
      apps/peertube-runner/.npmignore
  26. 43 0
      apps/peertube-runner/README.md
  27. 20 0
      apps/peertube-runner/package.json
  28. 26 0
      apps/peertube-runner/scripts/build.js
  29. 91 0
      apps/peertube-runner/src/peertube-runner.ts
  30. 1 0
      apps/peertube-runner/src/register/index.ts
  31. 36 0
      apps/peertube-runner/src/register/register.ts
  32. 1 0
      apps/peertube-runner/src/server/index.ts
  33. 2 0
      apps/peertube-runner/src/server/process/index.ts
  34. 34 0
      apps/peertube-runner/src/server/process/process.ts
  35. 106 0
      apps/peertube-runner/src/server/process/shared/common.ts
  36. 3 0
      apps/peertube-runner/src/server/process/shared/index.ts
  37. 338 0
      apps/peertube-runner/src/server/process/shared/process-live.ts
  38. 165 0
      apps/peertube-runner/src/server/process/shared/process-studio.ts
  39. 201 0
      apps/peertube-runner/src/server/process/shared/process-vod.ts
  40. 10 0
      apps/peertube-runner/src/server/process/shared/transcoding-logger.ts
  41. 307 0
      apps/peertube-runner/src/server/server.ts
  42. 1 0
      apps/peertube-runner/src/server/shared/index.ts
  43. 43 0
      apps/peertube-runner/src/server/shared/supported-job.ts
  44. 140 0
      apps/peertube-runner/src/shared/config-manager.ts
  45. 67 0
      apps/peertube-runner/src/shared/http.ts
  46. 3 0
      apps/peertube-runner/src/shared/index.ts
  47. 2 0
      apps/peertube-runner/src/shared/ipc/index.ts
  48. 88 0
      apps/peertube-runner/src/shared/ipc/ipc-client.ts
  49. 61 0
      apps/peertube-runner/src/shared/ipc/ipc-server.ts
  50. 2 0
      apps/peertube-runner/src/shared/ipc/shared/index.ts
  51. 0 0
      apps/peertube-runner/src/shared/ipc/shared/ipc-request.model.ts
  52. 0 0
      apps/peertube-runner/src/shared/ipc/shared/ipc-response.model.ts
  53. 12 0
      apps/peertube-runner/src/shared/logger.ts
  54. 16 0
      apps/peertube-runner/tsconfig.json
  55. 0 0
      apps/peertube-runner/yarn.lock
  56. 1 0
      client/.eslintrc.json
  57. 0 8
      client/e2e/wdio.main.conf.ts
  58. 8 2
      client/package.json
  59. 1 1
      client/src/app/+about/about-follows/about-follows.component.ts
  60. 1 1
      client/src/app/+about/about-instance/about-instance.component.ts
  61. 1 1
      client/src/app/+about/about-instance/about-instance.resolver.ts
  62. 1 1
      client/src/app/+about/about-instance/contact-admin-modal.component.ts
  63. 1 1
      client/src/app/+about/about-instance/instance-statistics.component.ts
  64. 1 1
      client/src/app/+accounts/account-video-channels/account-video-channels.component.ts
  65. 1 1
      client/src/app/+accounts/account-videos/account-videos.component.ts
  66. 1 1
      client/src/app/+accounts/accounts.component.ts
  67. 1 1
      client/src/app/+admin/admin.component.ts
  68. 1 1
      client/src/app/+admin/config/config.routes.ts
  69. 1 1
      client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts
  70. 1 1
      client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
  71. 1 1
      client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts
  72. 1 1
      client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
  73. 1 1
      client/src/app/+admin/config/shared/config.service.ts
  74. 1 1
      client/src/app/+admin/follows/followers-list/followers-list.component.ts
  75. 1 1
      client/src/app/+admin/follows/following-list/following-list.component.ts
  76. 1 1
      client/src/app/+admin/follows/follows.routes.ts
  77. 1 2
      client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
  78. 1 1
      client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts
  79. 1 1
      client/src/app/+admin/moderation/moderation.routes.ts
  80. 2 2
      client/src/app/+admin/moderation/registration-list/admin-registration.service.ts
  81. 1 1
      client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts
  82. 1 1
      client/src/app/+admin/moderation/registration-list/registration-list.component.ts
  83. 3 3
      client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
  84. 1 1
      client/src/app/+admin/overview/comments/video-comment-list.component.ts
  85. 1 1
      client/src/app/+admin/overview/comments/video-comment.routes.ts
  86. 1 1
      client/src/app/+admin/overview/users/user-edit/user-create.component.ts
  87. 2 3
      client/src/app/+admin/overview/users/user-edit/user-edit.ts
  88. 1 1
      client/src/app/+admin/overview/users/user-edit/user-password.component.ts
  89. 1 1
      client/src/app/+admin/overview/users/user-edit/user-update.component.ts
  90. 2 2
      client/src/app/+admin/overview/users/user-list/user-list.component.ts
  91. 1 1
      client/src/app/+admin/overview/users/users.routes.ts
  92. 2 2
      client/src/app/+admin/overview/videos/video-admin.service.ts
  93. 2 2
      client/src/app/+admin/overview/videos/video-list.component.ts
  94. 1 1
      client/src/app/+admin/overview/videos/video.routes.ts
  95. 4 4
      client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts
  96. 3 3
      client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
  97. 1 1
      client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts
  98. 1 1
      client/src/app/+admin/plugins/plugins.routes.ts
  99. 10 9
      client/src/app/+admin/plugins/shared/plugin-api.service.ts
  100. 2 2
      client/src/app/+admin/plugins/shared/plugin-card.component.ts

+ 13 - 10
.eslintrc.json

@@ -1,5 +1,6 @@
 {
   "extends": "standard-with-typescript",
+  "root": true,
   "rules": {
     "eol-last": [
       "error",
@@ -126,18 +127,20 @@
     ]
   },
   "ignorePatterns": [
-    "node_modules/",
-    "server/tests/fixtures"
+    "node_modules",
+    "packages/tests/fixtures",
+    "apps/**/dist",
+    "packages/**/dist",
+    "server/dist",
+    "packages/types-generator/tests",
+    "*.js",
+    "/client",
+    "/dist"
   ],
   "parserOptions": {
-    "EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true,
     "project": [
-      "./tsconfig.json",
-      "./shared/tsconfig.json",
-      "./scripts/tsconfig.json",
-      "./server/tsconfig.json",
-      "./server/tools/tsconfig.json",
-      "./packages/peertube-runner/tsconfig.json"
-    ]
+      "./tsconfig.eslint.json"
+    ],
+    "EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true
   }
 }

+ 17 - 14
.github/CONTRIBUTING.md

@@ -53,13 +53,25 @@ interested in, user interface, design, decentralized architecture...
 You can help to write the documentation of the REST API, code, architecture,
 demonstrations.
 
-For the REST API you can see the documentation in [/support/doc/api](https://github.com/Chocobozzz/PeerTube/tree/develop/support/doc/api) directory.
-Then, you can just open the `openapi.yaml` file in a special editor like [http://editor.swagger.io/](http://editor.swagger.io/) to easily see and edit the documentation. You can also use [redoc-cli](https://github.com/Redocly/redoc/blob/master/cli/README.md) and run `redoc-cli serve --watch support/doc/api/openapi.yaml` to see the final result.
+### User documentation
+
+The official user documentation is available on https://docs.joinpeertube.org/
+
+You can update it by writing markdown files in the following repository: https://framagit.org/framasoft/peertube/documentation/
+
+### REST API documentation
+
+The [REST API documentation](https://docs.joinpeertube.org/api-rest-reference.html) is generated from `support/doc/api/openapi.yaml` file.
+To quickly get a preview of your changes, you can generate the documentation *on the fly* using the following command:
+
+```
+npx @redocly/cli preview-docs ./support/doc/api/openapi.yaml
+```
 
 Some hints:
- * Routes are defined in [/server/controllers/](https://github.com/Chocobozzz/PeerTube/tree/develop/server/controllers) directory
- * Parameters validators are defined in [/server/middlewares/validators](https://github.com/Chocobozzz/PeerTube/tree/develop/server/middlewares/validators) directory
- * Models sent/received by the controllers are defined in [/shared/models](https://github.com/Chocobozzz/PeerTube/tree/develop/shared/models) directory
+ * Routes are defined in [/server/server/controllers/](https://github.com/Chocobozzz/PeerTube/tree/develop/server/server/controllers) directory
+ * Parameters validators are defined in [/server/server/middlewares/validators](https://github.com/Chocobozzz/PeerTube/tree/develop/server/server/middlewares/validators) directory
+ * Models sent/received by the controllers are defined in [/packages/models](https://github.com/Chocobozzz/PeerTube/tree/develop/packages/models) directory
 
 
 ## Improve the website
@@ -242,15 +254,6 @@ To test emails with PeerTube:
  * Run [mailslurper](http://mailslurper.com/)
  * Run PeerTube using mailslurper SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=dev node dist/server`
 
-### OpenAPI documentation
-
-The [REST API documentation](https://docs.joinpeertube.org/api-rest-reference.html) is generated from `support/doc/api/openapi.yaml` file.
-To quickly get a preview of your changes, you can generate the documentation *on the fly* using the following command:
-
-```
-npx @redocly/cli preview-docs ./support/doc/api/openapi.yaml
-```
-
 ### Environment variables
 
 PeerTube can be configured using environment variables.

+ 9 - 1
.github/actions/reusable-prepare-peertube-build/action.yml

@@ -32,4 +32,12 @@ runs:
 
     - name: Install peertube runner dependencies
       shell: bash
-      run: cd packages/peertube-runner && yarn install --frozen-lockfile
+      run: cd apps/peertube-runner && yarn install --frozen-lockfile
+
+    - name: Install peertube CLI dependencies
+      shell: bash
+      run: cd apps/peertube-cli && yarn install --frozen-lockfile
+
+    - name: Display PeerTube dependencies
+      shell: bash
+      run: ls -l node_modules/@peertube

+ 1 - 1
.github/workflows/benchmark.yml

@@ -71,7 +71,7 @@ jobs:
 
       - name: Run benchmark
         run: |
-          node dist/scripts/benchmark.js -o benchmark.json
+          npm run benchmark-server -- -o benchmark.json
 
       - name: Display result
         run: |

+ 1 - 1
.github/workflows/codeql/codeql-config.yml

@@ -1,4 +1,4 @@
 name: "PeerTube CodeQL config"
 
 paths-ignore:
- - server/tests
+ - packages/tests

+ 2 - 2
.github/workflows/stats.yml

@@ -36,12 +36,12 @@ jobs:
         run: |
           wget "https://github.com/boyter/scc/releases/download/v3.0.0/scc-3.0.0-x86_64-unknown-linux.zip"
           unzip "scc-3.0.0-x86_64-unknown-linux.zip"
-          ./scc --format=json --exclude-dir .git,node_modules,client/node_modules,client/dist,dist,yarn.lock,client/yarn.lock,client/src/locale,test1,test2,test3,client/src/assets/images,config,storage,server/tests/fixtures,support/openapi,.idea,.vscode,docker-volume,ffmpeg-3,ffmpeg-4 > ./scc.json
+          ./scc --format=json --exclude-dir .git,node_modules,client/node_modules,client/dist,dist,yarn.lock,client/yarn.lock,client/src/locale,test1,test2,test3,client/src/assets/images,config,storage,packages/tests/fixtures,support/openapi,.idea,.vscode,docker-volume,ffmpeg-3,ffmpeg-4 > ./scc.json
 
       - name: PeerTube client stats
         if: github.event_name != 'pull_request'
         run: |
-          node dist/scripts/client-build-stats.js > client-build-stats.json
+          npm run client:build-stats > client-build-stats.json
 
       - name: PeerTube client lighthouse report
         if: github.event_name != 'pull_request'

+ 9 - 7
.gitignore

@@ -1,8 +1,8 @@
 # NPM instalation
-/node_modules/
-/server/tools/node_modules
+node_modules
 *npm-debug.log
 yarn-error.log
+.yarn
 
 # Testing
 /test1/
@@ -11,8 +11,8 @@ yarn-error.log
 /test4/
 /test5/
 /test6/
-/server/tests/fixtures/video_high_bitrate_1080p.mp4
-/server/tests/fixtures/video_59fps.mp4
+/packages/tests/fixtures/video_high_bitrate_1080p.mp4
+/packages/tests/fixtures/video_59fps.mp4
 
 # Production
 /storage
@@ -49,12 +49,14 @@ yarn-error.log
 /*.tar.xz
 /*.asc
 *.DS_Store
-/server/tools/import-mediacore.ts
 /docker-volume/
 /init.mp4
 
 # TypeScript
 *.tsbuildinfo
 
-# Packages
-/packages/types/dist/
+# EsLint
+.eslintcache
+
+# Compiled output
+dist

+ 10 - 0
.mocharc.cjs

@@ -0,0 +1,10 @@
+process.env.ESBK_TSCONFIG_PATH = './packages/tests/tsconfig.json'
+
+module.exports = {
+  "node-option": [
+    "loader=tsx",
+    "no-warnings",
+    "conditions=peertube:tsx"
+  ],
+  "timeout": 30000
+}

+ 4 - 0
apps/peertube-cli/.npmignore

@@ -0,0 +1,4 @@
+src
+meta.json
+tsconfig.json
+scripts

+ 43 - 0
apps/peertube-cli/README.md

@@ -0,0 +1,43 @@
+# PeerTube CLI
+
+## Usage
+
+See https://docs.joinpeertube.org/maintain/tools#remote-tools
+
+## Dev
+
+## Install dependencies
+
+```bash
+cd peertube-root
+yarn install --pure-lockfile
+cd apps/peertube-cli && yarn install --pure-lockfile
+```
+
+## Develop
+
+```bash
+cd peertube-root
+npm run dev:peertube-cli
+```
+
+## Build
+
+```bash
+cd peertube-root
+npm run build:peertube-cli
+```
+
+## Run
+
+```bash
+cd peertube-root
+node apps/peertube-cli/dist/peertube-cli.js --help
+```
+
+## Publish on NPM
+
+```bash
+cd peertube-root
+(cd apps/peertube-cli && npm version patch) && npm run build:peertube-cli && (cd apps/peertube-cli && npm publish --access=public)
+```

+ 19 - 0
apps/peertube-cli/package.json

@@ -0,0 +1,19 @@
+{
+  "name": "@peertube/peertube-cli",
+  "version": "1.0.1",
+  "type": "module",
+  "main": "dist/peertube.js",
+  "bin": "dist/peertube.js",
+  "engines": {
+    "node": ">=16.x"
+  },
+  "scripts": {},
+  "license": "AGPL-3.0",
+  "private": false,
+  "devDependencies": {
+    "application-config": "^2.0.0",
+    "cli-table3": "^0.6.0",
+    "netrc-parser": "^3.1.6"
+  },
+  "dependencies": {}
+}

+ 27 - 0
apps/peertube-cli/scripts/build.js

@@ -0,0 +1,27 @@
+import * as esbuild from 'esbuild'
+import { readFileSync } from 'fs'
+
+const packageJSON = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)))
+
+export const esbuildOptions = {
+  entryPoints: [ './src/peertube.ts' ],
+  bundle: true,
+  platform: 'node',
+  format: 'esm',
+  target: 'node16',
+  external: [
+    './lib-cov/fluent-ffmpeg',
+    'pg-hstore'
+  ],
+  outfile: './dist/peertube.js',
+  banner: {
+    js: `const require = (await import("node:module")).createRequire(import.meta.url);` +
+      `const __filename = (await import("node:url")).fileURLToPath(import.meta.url);` +
+      `const __dirname = (await import("node:path")).dirname(__filename);`
+  },
+  define: {
+    'process.env.PACKAGE_VERSION': `'${packageJSON.version}'`
+  }
+}
+
+await esbuild.build(esbuildOptions)

+ 7 - 0
apps/peertube-cli/scripts/watch.js

@@ -0,0 +1,7 @@
+import * as esbuild from 'esbuild'
+import { esbuildOptions } from './build.js'
+
+const context = await esbuild.context(esbuildOptions)
+
+// Enable watch mode
+await context.watch()

+ 171 - 0
apps/peertube-cli/src/peertube-auth.ts

@@ -0,0 +1,171 @@
+import CliTable3 from 'cli-table3'
+import prompt from 'prompt'
+import { Command } from '@commander-js/extra-typings'
+import { assignToken, buildServer, getNetrc, getSettings, writeSettings } from './shared/index.js'
+
+export function defineAuthProgram () {
+  const program = new Command()
+    .name('auth')
+    .description('Register your accounts on remote instances to use them with other commands')
+
+  program
+    .command('add')
+    .description('remember your accounts on remote instances for easier use')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .option('--default', 'add the entry as the new default')
+    .action(options => {
+      /* eslint-disable no-import-assign */
+      prompt.override = options
+      prompt.start()
+      prompt.get({
+        properties: {
+          url: {
+            description: 'instance url',
+            conform: value => isURLaPeerTubeInstance(value),
+            message: 'It should be an URL (https://peertube.example.com)',
+            required: true
+          },
+          username: {
+            conform: value => typeof value === 'string' && value.length !== 0,
+            message: 'Name must be only letters, spaces, or dashes',
+            required: true
+          },
+          password: {
+            hidden: true,
+            replace: '*',
+            required: true
+          }
+        }
+      }, async (_, result) => {
+
+        // Check credentials
+        try {
+          // Strip out everything after the domain:port.
+          // See https://github.com/Chocobozzz/PeerTube/issues/3520
+          result.url = stripExtraneousFromPeerTubeUrl(result.url)
+
+          const server = buildServer(result.url)
+          await assignToken(server, result.username, result.password)
+        } catch (err) {
+          console.error(err.message)
+          process.exit(-1)
+        }
+
+        await setInstance(result.url, result.username, result.password, options.default)
+
+        process.exit(0)
+      })
+    })
+
+  program
+    .command('del <url>')
+    .description('Unregisters a remote instance')
+    .action(async url => {
+      await delInstance(url)
+
+      process.exit(0)
+    })
+
+  program
+    .command('list')
+    .description('List registered remote instances')
+    .action(async () => {
+      const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
+
+      const table = new CliTable3({
+        head: [ 'instance', 'login' ],
+        colWidths: [ 30, 30 ]
+      }) as any
+
+      settings.remotes.forEach(element => {
+        if (!netrc.machines[element]) return
+
+        table.push([
+          element,
+          netrc.machines[element].login
+        ])
+      })
+
+      console.log(table.toString())
+
+      process.exit(0)
+    })
+
+  program
+    .command('set-default <url>')
+    .description('Set an existing entry as default')
+    .action(async url => {
+      const settings = await getSettings()
+      const instanceExists = settings.remotes.includes(url)
+
+      if (instanceExists) {
+        settings.default = settings.remotes.indexOf(url)
+        await writeSettings(settings)
+
+        process.exit(0)
+      } else {
+        console.log('<url> is not a registered instance.')
+        process.exit(-1)
+      }
+    })
+
+  program.addHelpText('after', '\n\n  Examples:\n\n' +
+    '    $ peertube auth add -u https://peertube.cpy.re -U "PEERTUBE_USER" --password "PEERTUBE_PASSWORD"\n' +
+    '    $ peertube auth add -u https://peertube.cpy.re -U root\n' +
+    '    $ peertube auth list\n' +
+    '    $ peertube auth del https://peertube.cpy.re\n'
+  )
+
+  return program
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function delInstance (url: string) {
+  const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
+
+  const index = settings.remotes.indexOf(url)
+  settings.remotes.splice(index)
+
+  if (settings.default === index) settings.default = -1
+
+  await writeSettings(settings)
+
+  delete netrc.machines[url]
+
+  await netrc.save()
+}
+
+async function setInstance (url: string, username: string, password: string, isDefault: boolean) {
+  const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
+
+  if (settings.remotes.includes(url) === false) {
+    settings.remotes.push(url)
+  }
+
+  if (isDefault || settings.remotes.length === 1) {
+    settings.default = settings.remotes.length - 1
+  }
+
+  await writeSettings(settings)
+
+  netrc.machines[url] = { login: username, password }
+  await netrc.save()
+}
+
+function isURLaPeerTubeInstance (url: string) {
+  return url.startsWith('http://') || url.startsWith('https://')
+}
+
+function stripExtraneousFromPeerTubeUrl (url: string) {
+  // Get everything before the 3rd /.
+  const urlLength = url.includes('/', 8)
+    ? url.indexOf('/', 8)
+    : url.length
+
+  return url.substring(0, urlLength)
+}

+ 39 - 0
apps/peertube-cli/src/peertube-get-access-token.ts

@@ -0,0 +1,39 @@
+import { Command } from '@commander-js/extra-typings'
+import { assignToken, buildServer } from './shared/index.js'
+
+export function defineGetAccessProgram () {
+  const program = new Command()
+    .name('get-access-token')
+    .description('Get a peertube access token')
+    .alias('token')
+
+  program
+    .option('-u, --url <url>', 'Server url')
+    .option('-n, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .action(async options => {
+      try {
+        if (
+          !options.url ||
+          !options.username ||
+          !options.password
+        ) {
+          if (!options.url) console.error('--url field is required.')
+          if (!options.username) console.error('--username field is required.')
+          if (!options.password) console.error('--password field is required.')
+
+          process.exit(-1)
+        }
+
+        const server = buildServer(options.url)
+        await assignToken(server, options.username, options.password)
+
+        console.log(server.accessToken)
+      } catch (err) {
+        console.error('Cannot get access token: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  return program
+}

+ 167 - 0
apps/peertube-cli/src/peertube-plugins.ts

@@ -0,0 +1,167 @@
+import CliTable3 from 'cli-table3'
+import { isAbsolute } from 'path'
+import { Command } from '@commander-js/extra-typings'
+import { PluginType, PluginType_Type } from '@peertube/peertube-models'
+import { assignToken, buildServer, CommonProgramOptions, getServerCredentials } from './shared/index.js'
+
+export function definePluginsProgram () {
+  const program = new Command()
+
+  program
+    .name('plugins')
+    .description('Manage instance plugins/themes')
+    .alias('p')
+
+  program
+    .command('list')
+    .description('List installed plugins')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .option('-t, --only-themes', 'List themes only')
+    .option('-P, --only-plugins', 'List plugins only')
+    .action(async options => {
+      try {
+        await pluginsListCLI(options)
+      } catch (err) {
+        console.error('Cannot list plugins: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  program
+    .command('install')
+    .description('Install a plugin or a theme')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .option('-P --path <path>', 'Install from a path')
+    .option('-n, --npm-name <npmName>', 'Install from npm')
+    .option('--plugin-version <pluginVersion>', 'Specify the plugin version to install (only available when installing from npm)')
+    .action(async options => {
+      try {
+        await installPluginCLI(options)
+      } catch (err) {
+        console.error('Cannot install plugin: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  program
+    .command('update')
+    .description('Update a plugin or a theme')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .option('-P --path <path>', 'Update from a path')
+    .option('-n, --npm-name <npmName>', 'Update from npm')
+    .action(async options => {
+      try {
+        await updatePluginCLI(options)
+      } catch (err) {
+        console.error('Cannot update plugin: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  program
+    .command('uninstall')
+    .description('Uninstall a plugin or a theme')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .option('-n, --npm-name <npmName>', 'NPM plugin/theme name')
+    .action(async options => {
+      try {
+        await uninstallPluginCLI(options)
+      } catch (err) {
+        console.error('Cannot uninstall plugin: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  return program
+}
+
+// ----------------------------------------------------------------------------
+
+async function pluginsListCLI (options: CommonProgramOptions & { onlyThemes?: true, onlyPlugins?: true }) {
+  const { url, username, password } = await getServerCredentials(options)
+  const server = buildServer(url)
+  await assignToken(server, username, password)
+
+  let pluginType: PluginType_Type
+  if (options.onlyThemes) pluginType = PluginType.THEME
+  if (options.onlyPlugins) pluginType = PluginType.PLUGIN
+
+  const { data } = await server.plugins.list({ start: 0, count: 100, sort: 'name', pluginType })
+
+  const table = new CliTable3({
+    head: [ 'name', 'version', 'homepage' ],
+    colWidths: [ 50, 20, 50 ]
+  }) as any
+
+  for (const plugin of data) {
+    const npmName = plugin.type === PluginType.PLUGIN
+      ? 'peertube-plugin-' + plugin.name
+      : 'peertube-theme-' + plugin.name
+
+    table.push([
+      npmName,
+      plugin.version,
+      plugin.homepage
+    ])
+  }
+
+  console.log(table.toString())
+}
+
+async function installPluginCLI (options: CommonProgramOptions & { path?: string, npmName?: string, pluginVersion?: string }) {
+  if (!options.path && !options.npmName) {
+    throw new Error('You need to specify the npm name or the path of the plugin you want to install.')
+  }
+
+  if (options.path && !isAbsolute(options.path)) {
+    throw new Error('Path should be absolute.')
+  }
+
+  const { url, username, password } = await getServerCredentials(options)
+  const server = buildServer(url)
+  await assignToken(server, username, password)
+
+  await server.plugins.install({ npmName: options.npmName, path: options.path, pluginVersion: options.pluginVersion })
+
+  console.log('Plugin installed.')
+}
+
+async function updatePluginCLI (options: CommonProgramOptions & { path?: string, npmName?: string }) {
+  if (!options.path && !options.npmName) {
+    throw new Error('You need to specify the npm name or the path of the plugin you want to update.')
+  }
+
+  if (options.path && !isAbsolute(options.path)) {
+    throw new Error('Path should be absolute.')
+  }
+
+  const { url, username, password } = await getServerCredentials(options)
+  const server = buildServer(url)
+  await assignToken(server, username, password)
+
+  await server.plugins.update({ npmName: options.npmName, path: options.path })
+
+  console.log('Plugin updated.')
+}
+
+async function uninstallPluginCLI (options: CommonProgramOptions & { npmName?: string }) {
+  if (!options.npmName) {
+    throw new Error('You need to specify the npm name of the plugin/theme you want to uninstall.')
+  }
+
+  const { url, username, password } = await getServerCredentials(options)
+  const server = buildServer(url)
+  await assignToken(server, username, password)
+
+  await server.plugins.uninstall({ npmName: options.npmName })
+
+  console.log('Plugin uninstalled.')
+}

+ 186 - 0
apps/peertube-cli/src/peertube-redundancy.ts

@@ -0,0 +1,186 @@
+import bytes from 'bytes'
+import CliTable3 from 'cli-table3'
+import { URL } from 'url'
+import { Command } from '@commander-js/extra-typings'
+import { forceNumber, uniqify } from '@peertube/peertube-core-utils'
+import { HttpStatusCode, VideoRedundanciesTarget } from '@peertube/peertube-models'
+import { assignToken, buildServer, CommonProgramOptions, getServerCredentials } from './shared/index.js'
+
+export function defineRedundancyProgram () {
+  const program = new Command()
+    .name('redundancy')
+    .description('Manage instance redundancies')
+    .alias('r')
+
+  program
+    .command('list-remote-redundancies')
+    .description('List remote redundancies on your videos')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .action(async options => {
+      try {
+        await listRedundanciesCLI({ target: 'my-videos', ...options })
+      } catch (err) {
+        console.error('Cannot list remote redundancies: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  program
+    .command('list-my-redundancies')
+    .description('List your redundancies of remote videos')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .action(async options => {
+      try {
+        await listRedundanciesCLI({ target: 'remote-videos', ...options })
+      } catch (err) {
+        console.error('Cannot list redundancies: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  program
+    .command('add')
+    .description('Duplicate a video in your redundancy system')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .requiredOption('-v, --video <videoId>', 'Video id to duplicate', parseInt)
+    .action(async options => {
+      try {
+        await addRedundancyCLI(options)
+      } catch (err) {
+        console.error('Cannot duplicate video: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  program
+    .command('remove')
+    .description('Remove a video from your redundancies')
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .requiredOption('-v, --video <videoId>', 'Video id to remove from redundancies', parseInt)
+    .action(async options => {
+      try {
+        await removeRedundancyCLI(options)
+      } catch (err) {
+        console.error('Cannot remove redundancy: ' + err)
+        process.exit(-1)
+      }
+    })
+
+  return program
+}
+
+// ----------------------------------------------------------------------------
+
+async function listRedundanciesCLI (options: CommonProgramOptions & { target: VideoRedundanciesTarget }) {
+  const { target } = options
+
+  const { url, username, password } = await getServerCredentials(options)
+  const server = buildServer(url)
+  await assignToken(server, username, password)
+
+  const { data } = await server.redundancy.listVideos({ start: 0, count: 100, sort: 'name', target })
+
+  const table = new CliTable3({
+    head: [ 'video id', 'video name', 'video url', 'files', 'playlists', 'by instances', 'total size' ]
+  }) as any
+
+  for (const redundancy of data) {
+    const webVideoFiles = redundancy.redundancies.files
+    const streamingPlaylists = redundancy.redundancies.streamingPlaylists
+
+    let totalSize = ''
+    if (target === 'remote-videos') {
+      const tmp = webVideoFiles.concat(streamingPlaylists)
+        .reduce((a, b) => a + b.size, 0)
+
+      // FIXME: don't use external dependency to stringify bytes: we already have the functions in the client
+      totalSize = bytes(tmp)
+    }
+
+    const instances = uniqify(
+      webVideoFiles.concat(streamingPlaylists)
+        .map(r => r.fileUrl)
+        .map(u => new URL(u).host)
+    )
+
+    table.push([
+      redundancy.id.toString(),
+      redundancy.name,
+      redundancy.url,
+      webVideoFiles.length,
+      streamingPlaylists.length,
+      instances.join('\n'),
+      totalSize
+    ])
+  }
+
+  console.log(table.toString())
+}
+
+async function addRedundancyCLI (options: { video: number } & CommonProgramOptions) {
+  const { url, username, password } = await getServerCredentials(options)
+  const server = buildServer(url)
+  await assignToken(server, username, password)
+
+  if (!options.video || isNaN(options.video)) {
+    throw new Error('You need to specify the video id to duplicate and it should be a number.')
+  }
+
+  try {
+    await server.redundancy.addVideo({ videoId: options.video })
+
+    console.log('Video will be duplicated by your instance!')
+  } catch (err) {
+    if (err.message.includes(HttpStatusCode.CONFLICT_409)) {
+      throw new Error('This video is already duplicated by your instance.')
+    }
+
+    if (err.message.includes(HttpStatusCode.NOT_FOUND_404)) {
+      throw new Error('This video id does not exist.')
+    }
+
+    throw err
+  }
+}
+
+async function removeRedundancyCLI (options: CommonProgramOptions & { video: number }) {
+  const { url, username, password } = await getServerCredentials(options)
+  const server = buildServer(url)
+  await assignToken(server, username, password)
+
+  if (!options.video || isNaN(options.video)) {
+    throw new Error('You need to specify the video id to remove from your redundancies')
+  }
+
+  const videoId = forceNumber(options.video)
+
+  const myVideoRedundancies = await server.redundancy.listVideos({ target: 'my-videos' })
+  let videoRedundancy = myVideoRedundancies.data.find(r => videoId === r.id)
+
+  if (!videoRedundancy) {
+    const remoteVideoRedundancies = await server.redundancy.listVideos({ target: 'remote-videos' })
+    videoRedundancy = remoteVideoRedundancies.data.find(r => videoId === r.id)
+  }
+
+  if (!videoRedundancy) {
+    throw new Error('Video redundancy not found.')
+  }
+
+  const ids = videoRedundancy.redundancies.files
+    .concat(videoRedundancy.redundancies.streamingPlaylists)
+    .map(r => r.id)
+
+  for (const id of ids) {
+    await server.redundancy.removeVideo({ redundancyId: id })
+  }
+
+  console.log('Video redundancy removed!')
+}

+ 167 - 0
apps/peertube-cli/src/peertube-upload.ts

@@ -0,0 +1,167 @@
+import { access, constants } from 'fs/promises'
+import { isAbsolute } from 'path'
+import { inspect } from 'util'
+import { Command } from '@commander-js/extra-typings'
+import { VideoPrivacy } from '@peertube/peertube-models'
+import { PeerTubeServer } from '@peertube/peertube-server-commands'
+import { assignToken, buildServer, getServerCredentials, listOptions } from './shared/index.js'
+
+type UploadOptions = {
+  url?: string
+  username?: string
+  password?: string
+  thumbnail?: string
+  preview?: string
+  file?: string
+  videoName?: string
+  category?: string
+  licence?: string
+  language?: string
+  tags?: string
+  nsfw?: true
+  videoDescription?: string
+  privacy?: number
+  channelName?: string
+  noCommentsEnabled?: true
+  support?: string
+  noWaitTranscoding?: true
+  noDownloadEnabled?: true
+}
+
+export function defineUploadProgram () {
+  const program = new Command('upload')
+    .description('Upload a video on a PeerTube instance')
+    .alias('up')
+
+  program
+    .option('-u, --url <url>', 'Server url')
+    .option('-U, --username <username>', 'Username')
+    .option('-p, --password <token>', 'Password')
+    .option('-b, --thumbnail <thumbnailPath>', 'Thumbnail path')
+    .option('-v, --preview <previewPath>', 'Preview path')
+    .option('-f, --file <file>', 'Video absolute file path')
+    .option('-n, --video-name <name>', 'Video name')
+    .option('-c, --category <category_number>', 'Category number')
+    .option('-l, --licence <licence_number>', 'Licence number')
+    .option('-L, --language <language_code>', 'Language ISO 639 code (fr or en...)')
+    .option('-t, --tags <tags>', 'Video tags', listOptions)
+    .option('-N, --nsfw', 'Video is Not Safe For Work')
+    .option('-d, --video-description <description>', 'Video description')
+    .option('-P, --privacy <privacy_number>', 'Privacy', parseInt)
+    .option('-C, --channel-name <channel_name>', 'Channel name')
+    .option('--no-comments-enabled', 'Disable video comments')
+    .option('-s, --support <support>', 'Video support text')
+    .option('--no-wait-transcoding', 'Do not wait transcoding before publishing the video')
+    .option('--no-download-enabled', 'Disable video download')
+    .option('-v, --verbose <verbose>', 'Verbosity, from 0/\'error\' to 4/\'debug\'', 'info')
+    .action(async options => {
+      try {
+        const { url, username, password } = await getServerCredentials(options)
+
+        if (!options.videoName || !options.file) {
+          if (!options.videoName) console.error('--video-name is required.')
+          if (!options.file) console.error('--file is required.')
+
+          process.exit(-1)
+        }
+
+        if (isAbsolute(options.file) === false) {
+          console.error('File path should be absolute.')
+          process.exit(-1)
+        }
+
+        await run({ ...options, url, username, password })
+      } catch (err) {
+        console.error('Cannot upload video: ' + err.message)
+        process.exit(-1)
+      }
+    })
+
+  return program
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function run (options: UploadOptions) {
+  const { url, username, password } = options
+
+  const server = buildServer(url)
+  await assignToken(server, username, password)
+
+  await access(options.file, constants.F_OK)
+
+  console.log('Uploading %s video...', options.videoName)
+
+  const baseAttributes = await buildVideoAttributesFromCommander(server, options)
+
+  const attributes = {
+    ...baseAttributes,
+
+    fixture: options.file,
+    thumbnailfile: options.thumbnail,
+    previewfile: options.preview
+  }
+
+  try {
+    await server.videos.upload({ attributes })
+    console.log(`Video ${options.videoName} uploaded.`)
+    process.exit(0)
+  } catch (err) {
+    const message = err.message || ''
+    if (message.includes('413')) {
+      console.error('Aborted: user quota is exceeded or video file is too big for this PeerTube instance.')
+    } else {
+      console.error(inspect(err))
+    }
+
+    process.exit(-1)
+  }
+}
+
+async function buildVideoAttributesFromCommander (server: PeerTubeServer, options: UploadOptions, defaultAttributes: any = {}) {
+  const defaultBooleanAttributes = {
+    nsfw: false,
+    commentsEnabled: true,
+    downloadEnabled: true,
+    waitTranscoding: true
+  }
+
+  const booleanAttributes: { [id in keyof typeof defaultBooleanAttributes]: boolean } | {} = {}
+
+  for (const key of Object.keys(defaultBooleanAttributes)) {
+    if (options[key] !== undefined) {
+      booleanAttributes[key] = options[key]
+    } else if (defaultAttributes[key] !== undefined) {
+      booleanAttributes[key] = defaultAttributes[key]
+    } else {
+      booleanAttributes[key] = defaultBooleanAttributes[key]
+    }
+  }
+
+  const videoAttributes = {
+    name: options.videoName || defaultAttributes.name,
+    category: options.category || defaultAttributes.category || undefined,
+    licence: options.licence || defaultAttributes.licence || undefined,
+    language: options.language || defaultAttributes.language || undefined,
+    privacy: options.privacy || defaultAttributes.privacy || VideoPrivacy.PUBLIC,
+    support: options.support || defaultAttributes.support || undefined,
+    description: options.videoDescription || defaultAttributes.description || undefined,
+    tags: options.tags || defaultAttributes.tags || undefined
+  }
+
+  Object.assign(videoAttributes, booleanAttributes)
+
+  if (options.channelName) {
+    const videoChannel = await server.channels.get({ channelName: options.channelName })
+
+    Object.assign(videoAttributes, { channelId: videoChannel.id })
+
+    if (!videoAttributes.support && videoChannel.support) {
+      Object.assign(videoAttributes, { support: videoChannel.support })
+    }
+  }
+
+  return videoAttributes
+}

+ 64 - 0
apps/peertube-cli/src/peertube.ts

@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+
+import { Command } from '@commander-js/extra-typings'
+import { defineAuthProgram } from './peertube-auth.js'
+import { defineGetAccessProgram } from './peertube-get-access-token.js'
+import { definePluginsProgram } from './peertube-plugins.js'
+import { defineRedundancyProgram } from './peertube-redundancy.js'
+import { defineUploadProgram } from './peertube-upload.js'
+import { getSettings, version } from './shared/index.js'
+
+const program = new Command()
+
+program
+  .version(version, '-v, --version')
+  .usage('[command] [options]')
+
+program.addCommand(defineAuthProgram())
+program.addCommand(defineUploadProgram())
+program.addCommand(defineRedundancyProgram())
+program.addCommand(definePluginsProgram())
+program.addCommand(defineGetAccessProgram())
+
+// help on no command
+if (!process.argv.slice(2).length) {
+  const logo = '░P░e░e░r░T░u░b░e░'
+  console.log(`
+  ___/),.._                           ` + logo + `
+/'   ,.   ."'._
+(     "'   '-.__"-._             ,-
+\\'='='),  "\\ -._-"-.          -"/
+      / ""/"\\,_\\,__""       _" /,-
+     /   /                -" _/"/
+    /   |    ._\\\\ |\\  |_.".-"  /
+   /    |   __\\)|)|),/|_." _,."
+  /     \\_."   " ") | ).-""---''--
+ (                  "/.""7__-""''
+ |                   " ."._--._
+ \\       \\ (_    __   ""   ".,_
+  \\.,.    \\  ""   -"".-"
+   ".,_,  (",_-,,,-".-
+       "'-,\\_   __,-"
+             ",)" ")
+              /"\\-"
+            ,"\\/
+      _,.__/"\\/_                     (the CLI for red chocobos)
+     / \\) "./,  ".
+  --/---"---" "-) )---- by Chocobozzz et al.\n`)
+}
+
+getSettings()
+  .then(settings => {
+    const state = (settings.default === undefined || settings.default === -1)
+      ? 'no instance selected, commands will require explicit arguments'
+      : 'instance ' + settings.remotes[settings.default] + ' selected'
+
+    program
+      .addHelpText('after', '\n\n  State: ' + state + '\n\n' +
+        '  Examples:\n\n' +
+        '    $ peertube auth add -u "PEERTUBE_URL" -U "PEERTUBE_USER" --password "PEERTUBE_PASSWORD"\n' +
+        '    $ peertube up <videoFile>\n'
+      )
+      .parse(process.argv)
+  })
+  .catch(err => console.error(err))

+ 195 - 0
apps/peertube-cli/src/shared/cli.ts

@@ -0,0 +1,195 @@
+import applicationConfig from 'application-config'
+import { Netrc } from 'netrc-parser'
+import { join } from 'path'
+import { createLogger, format, transports } from 'winston'
+import { UserRole } from '@peertube/peertube-models'
+import { getAppNumber, isTestInstance, root } from '@peertube/peertube-node-utils'
+import { PeerTubeServer } from '@peertube/peertube-server-commands'
+
+export type CommonProgramOptions = {
+  url?: string
+  username?: string
+  password?: string
+}
+
+let configName = 'PeerTube/CLI'
+if (isTestInstance()) configName += `-${getAppNumber()}`
+
+const config = applicationConfig(configName)
+
+const version: string = process.env.PACKAGE_VERSION
+
+async function getAdminTokenOrDie (server: PeerTubeServer, username: string, password: string) {
+  const token = await server.login.getAccessToken(username, password)
+  const me = await server.users.getMyInfo({ token })
+
+  if (me.role.id !== UserRole.ADMINISTRATOR) {
+    console.error('You must be an administrator.')
+    process.exit(-1)
+  }
+
+  return token
+}
+
+interface Settings {
+  remotes: any[]
+  default: number
+}
+
+async function getSettings () {
+  const defaultSettings: Settings = {
+    remotes: [],
+    default: -1
+  }
+
+  const data = await config.read() as Promise<Settings>
+
+  return Object.keys(data).length === 0
+    ? defaultSettings
+    : data
+}
+
+async function getNetrc () {
+  const netrc = isTestInstance()
+    ? new Netrc(join(root(import.meta.url), 'test' + getAppNumber(), 'netrc'))
+    : new Netrc()
+
+  await netrc.load()
+
+  return netrc
+}
+
+function writeSettings (settings: Settings) {
+  return config.write(settings)
+}
+
+function deleteSettings () {
+  return config.trash()
+}
+
+function getRemoteObjectOrDie (
+  options: CommonProgramOptions,
+  settings: Settings,
+  netrc: Netrc
+): { url: string, username: string, password: string } {
+
+  function exitIfNoOptions (optionNames: string[], errorPrefix: string = '') {
+    let exit = false
+
+    for (const key of optionNames) {
+      if (!options[key]) {
+        if (exit === false && errorPrefix) console.error(errorPrefix)
+
+        console.error(`--${key} field is required`)
+        exit = true
+      }
+    }
+
+    if (exit) process.exit(-1)
+  }
+
+  // If username or password are specified, both are mandatory
+  if (options.username || options.password) {
+    exitIfNoOptions([ 'username', 'password' ])
+  }
+
+  // If no available machines, url, username and password args are mandatory
+  if (Object.keys(netrc.machines).length === 0) {
+    exitIfNoOptions([ 'url', 'username', 'password' ], 'No account found in netrc')
+  }
+
+  if (settings.remotes.length === 0 || settings.default === -1) {
+    exitIfNoOptions([ 'url' ], 'No default instance found')
+  }
+
+  let url: string = options.url
+  let username: string = options.username
+  let password: string = options.password
+
+  if (!url && settings.default !== -1) url = settings.remotes[settings.default]
+
+  const machine = netrc.machines[url]
+  if ((!username || !password) && !machine) {
+    console.error('Cannot find existing configuration for %s.', url)
+    process.exit(-1)
+  }
+
+  if (!username && machine) username = machine.login
+  if (!password && machine) password = machine.password
+
+  return { url, username, password }
+}
+
+function listOptions (val: any) {
+  return val.split(',')
+}
+
+function getServerCredentials (options: CommonProgramOptions) {
+  return Promise.all([ getSettings(), getNetrc() ])
+                .then(([ settings, netrc ]) => {
+                  return getRemoteObjectOrDie(options, settings, netrc)
+                })
+}
+
+function buildServer (url: string) {
+  return new PeerTubeServer({ url })
+}
+
+async function assignToken (server: PeerTubeServer, username: string, password: string) {
+  const bodyClient = await server.login.getClient()
+  const client = { id: bodyClient.client_id, secret: bodyClient.client_secret }
+
+  const body = await server.login.login({ client, user: { username, password } })
+
+  server.accessToken = body.access_token
+}
+
+function getLogger (logLevel = 'info') {
+  const logLevels = {
+    0: 0,
+    error: 0,
+    1: 1,
+    warn: 1,
+    2: 2,
+    info: 2,
+    3: 3,
+    verbose: 3,
+    4: 4,
+    debug: 4
+  }
+
+  const logger = createLogger({
+    levels: logLevels,
+    format: format.combine(
+      format.splat(),
+      format.simple()
+    ),
+    transports: [
+      new (transports.Console)({
+        level: logLevel
+      })
+    ]
+  })
+
+  return logger
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  version,
+  getLogger,
+  getSettings,
+  getNetrc,
+  getRemoteObjectOrDie,
+  writeSettings,
+  deleteSettings,
+
+  getServerCredentials,
+
+  listOptions,
+
+  getAdminTokenOrDie,
+  buildServer,
+  assignToken
+}

+ 1 - 0
apps/peertube-cli/src/shared/index.ts

@@ -0,0 +1 @@
+export * from './cli.js'

+ 15 - 0
apps/peertube-cli/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": "./",
+    "outDir": "./dist",
+    "rootDir": "src",
+    "tsBuildInfoFile": "./dist/.tsbuildinfo"
+  },
+  "references": [
+    { "path": "../../packages/core-utils" },
+    { "path": "../../packages/models" },
+    { "path": "../../packages/node-utils" },
+    { "path": "../../packages/server-commands" }
+  ]
+}

+ 374 - 0
apps/peertube-cli/yarn.lock

@@ -0,0 +1,374 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.0.0":
+  version "7.22.10"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.10.tgz#1c20e612b768fefa75f6e90d6ecb86329247f0a3"
+  integrity sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==
+  dependencies:
+    "@babel/highlight" "^7.22.10"
+    chalk "^2.4.2"
+
+"@babel/helper-validator-identifier@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193"
+  integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==
+
+"@babel/highlight@^7.22.10":
+  version "7.22.10"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.10.tgz#02a3f6d8c1cb4521b2fd0ab0da8f4739936137d7"
+  integrity sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.22.5"
+    chalk "^2.4.2"
+    js-tokens "^4.0.0"
+
+"@colors/colors@1.5.0":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
+  integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
+
+ansi-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+  dependencies:
+    color-convert "^1.9.0"
+
+application-config-path@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/application-config-path/-/application-config-path-0.1.1.tgz#8b5ac64ff6afdd9bd70ce69f6f64b6998f5f756e"
+  integrity sha512-zy9cHePtMP0YhwG+CfHm0bgwdnga2X3gZexpdCwEj//dpb+TKajtiC8REEUJUSq6Ab4f9cgNy2l8ObXzCXFkEw==
+
+application-config@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/application-config/-/application-config-2.0.0.tgz#15b4d54d61c0c082f9802227e3e85de876b47747"
+  integrity sha512-NC5/0guSZK3/UgUDfCk/riByXzqz0owL1L3r63JPSBzYk5QALrp3bLxbsR7qeSfvYfFmAhnp3dbqYsW3U9MpZQ==
+  dependencies:
+    application-config-path "^0.1.0"
+    load-json-file "^6.2.0"
+    write-json-file "^4.2.0"
+
+chalk@^2.4.2:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
+cli-table3@^0.6.0:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2"
+  integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==
+  dependencies:
+    string-width "^4.2.0"
+  optionalDependencies:
+    "@colors/colors" "1.5.0"
+
+color-convert@^1.9.0:
+  version "1.9.3"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+  dependencies:
+    color-name "1.1.3"
+
+color-name@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+  integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
+
+cross-spawn@^6.0.0:
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
+  dependencies:
+    nice-try "^1.0.4"
+    path-key "^2.0.1"
+    semver "^5.5.0"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+debug@^3.1.0:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
+  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
+  dependencies:
+    ms "^2.1.1"
+
+detect-indent@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6"
+  integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==
+
+emoji-regex@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+error-ex@^1.3.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
+  dependencies:
+    is-arrayish "^0.2.1"
+
+escape-string-regexp@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+  integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+
+execa@^0.10.0:
+  version "0.10.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50"
+  integrity sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==
+  dependencies:
+    cross-spawn "^6.0.0"
+    get-stream "^3.0.0"
+    is-stream "^1.1.0"
+    npm-run-path "^2.0.0"
+    p-finally "^1.0.0"
+    signal-exit "^3.0.0"
+    strip-eof "^1.0.0"
+
+get-stream@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+  integrity sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==
+
+graceful-fs@^4.1.15:
+  version "4.2.11"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
+  integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+
+has-flag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
+
+imurmurhash@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+  integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
+
+is-arrayish@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
+  integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
+
+is-fullwidth-code-point@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-plain-obj@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
+  integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
+
+is-stream@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
+  integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==
+
+is-typedarray@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+  integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==
+
+isexe@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
+
+js-tokens@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+json-parse-even-better-errors@^2.3.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
+  integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
+
+lines-and-columns@^1.1.6:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
+  integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+
+load-json-file@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-6.2.0.tgz#5c7770b42cafa97074ca2848707c61662f4251a1"
+  integrity sha512-gUD/epcRms75Cw8RT1pUdHugZYM5ce64ucs2GEISABwkRsOQr0q2wm/MV2TKThycIe5e0ytRweW2RZxclogCdQ==
+  dependencies:
+    graceful-fs "^4.1.15"
+    parse-json "^5.0.0"
+    strip-bom "^4.0.0"
+    type-fest "^0.6.0"
+
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
+ms@^2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+netrc-parser@^3.1.6:
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/netrc-parser/-/netrc-parser-3.1.6.tgz#7243c9ec850b8e805b9bdc7eae7b1450d4a96e72"
+  integrity sha512-lY+fmkqSwntAAjfP63jB4z5p5WbuZwyMCD3pInT7dpHU/Gc6Vv90SAC6A0aNiqaRGHiuZFBtiwu+pu8W/Eyotw==
+  dependencies:
+    debug "^3.1.0"
+    execa "^0.10.0"
+
+nice-try@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
+
+npm-run-path@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
+  integrity sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==
+  dependencies:
+    path-key "^2.0.0"
+
+p-finally@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+  integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==
+
+parse-json@^5.0.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
+  integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    error-ex "^1.3.1"
+    json-parse-even-better-errors "^2.3.0"
+    lines-and-columns "^1.1.6"
+
+path-key@^2.0.0, path-key@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+  integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==
+
+semver@^5.5.0:
+  version "5.7.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
+  integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
+
+semver@^6.0.0:
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+  integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
+
+shebang-command@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+  integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==
+  dependencies:
+    shebang-regex "^1.0.0"
+
+shebang-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+  integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==
+
+signal-exit@^3.0.0, signal-exit@^3.0.2:
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
+  integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
+
+sort-keys@^4.0.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-4.2.0.tgz#6b7638cee42c506fff8c1cecde7376d21315be18"
+  integrity sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg==
+  dependencies:
+    is-plain-obj "^2.0.0"
+
+string-width@^4.2.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
+strip-bom@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878"
+  integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==
+
+strip-eof@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
+  integrity sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==
+
+supports-color@^5.3.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+  dependencies:
+    has-flag "^3.0.0"
+
+type-fest@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
+  integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
+
+typedarray-to-buffer@^3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
+  integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
+  dependencies:
+    is-typedarray "^1.0.0"
+
+which@^1.2.9:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+  integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
+  dependencies:
+    isexe "^2.0.0"
+
+write-file-atomic@^3.0.0:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
+  integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==
+  dependencies:
+    imurmurhash "^0.1.4"
+    is-typedarray "^1.0.0"
+    signal-exit "^3.0.2"
+    typedarray-to-buffer "^3.1.5"
+
+write-json-file@^4.2.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/write-json-file/-/write-json-file-4.3.0.tgz#908493d6fd23225344af324016e4ca8f702dd12d"
+  integrity sha512-PxiShnxf0IlnQuMYOPPhPkhExoCQuTUNPOa/2JWCYTmBquU9njyyDuwRKN26IZBlp4yn1nt+Agh2HOOBl+55HQ==
+  dependencies:
+    detect-indent "^6.0.0"
+    graceful-fs "^4.1.15"
+    is-plain-obj "^2.0.0"
+    make-dir "^3.0.0"
+    sort-keys "^4.0.0"
+    write-file-atomic "^3.0.0"

+ 0 - 0
packages/peertube-runner/.gitignore → apps/peertube-runner/.gitignore


+ 4 - 0
apps/peertube-runner/.npmignore

@@ -0,0 +1,4 @@
+src
+meta.json
+tsconfig.json
+scripts

+ 43 - 0
apps/peertube-runner/README.md

@@ -0,0 +1,43 @@
+# PeerTube runner
+
+Runner program to execute jobs (transcoding...) of remote PeerTube instances.
+
+Commands below has to be run at the root of PeerTube git repository.
+
+## Dev
+
+### Install dependencies
+
+```bash
+cd peertube-root
+yarn install --pure-lockfile
+cd apps/peertube-runner && yarn install --pure-lockfile
+```
+
+### Develop
+
+```bash
+cd peertube-root
+npm run dev:peertube-runner
+```
+
+### Build
+
+```bash
+cd peertube-root
+npm run build:peertube-runner
+```
+
+### Run
+
+```bash
+cd peertube-root
+node apps/peertube-runner/dist/peertube-runner.js --help
+```
+
+### Publish on NPM
+
+```bash
+cd peertube-root
+(cd apps/peertube-runner && npm version patch) && npm run build:peertube-runner && (cd apps/peertube-runner && npm publish --access=public)
+```

+ 20 - 0
apps/peertube-runner/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "@peertube/peertube-runner",
+  "version": "0.0.5",
+  "type": "module",
+  "main": "dist/peertube-runner.js",
+  "bin": "dist/peertube-runner.js",
+  "engines": {
+    "node": ">=16.x"
+  },
+  "license": "AGPL-3.0",
+  "dependencies": {},
+  "devDependencies": {
+    "@commander-js/extra-typings": "^10.0.3",
+    "@iarna/toml": "^2.2.5",
+    "env-paths": "^3.0.0",
+    "net-ipc": "^2.0.1",
+    "pino": "^8.11.0",
+    "pino-pretty": "^10.0.0"
+  }
+}

+ 26 - 0
apps/peertube-runner/scripts/build.js

@@ -0,0 +1,26 @@
+import * as esbuild from 'esbuild'
+
+const packageJSON = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)))
+
+export const esbuildOptions = {
+  entryPoints: [ './src/peertube-runner.ts' ],
+  bundle: true,
+  platform: 'node',
+  format: 'esm',
+  target: 'node16',
+  external: [
+    './lib-cov/fluent-ffmpeg',
+    'pg-hstore'
+  ],
+  outfile: './dist/peertube-runner.js',
+  banner: {
+    js: `const require = (await import("node:module")).createRequire(import.meta.url);` +
+      `const __filename = (await import("node:url")).fileURLToPath(import.meta.url);` +
+      `const __dirname = (await import("node:path")).dirname(__filename);`
+  },
+  define: {
+    'process.env.PACKAGE_VERSION': `'${packageJSON.version}'`
+  }
+}
+
+await esbuild.build(esbuildOptions)

+ 91 - 0
apps/peertube-runner/src/peertube-runner.ts

@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+
+import { Command, InvalidArgumentError } from '@commander-js/extra-typings'
+import { listRegistered, registerRunner, unregisterRunner } from './register/index.js'
+import { RunnerServer } from './server/index.js'
+import { ConfigManager, logger } from './shared/index.js'
+
+const program = new Command()
+  .version(process.env.PACKAGE_VERSION)
+  .option(
+    '--id <id>',
+    'Runner server id, so you can run multiple PeerTube server runners with different configurations on the same machine',
+    'default'
+  )
+  .option('--verbose', 'Run in verbose mode')
+  .hook('preAction', thisCommand => {
+    const options = thisCommand.opts()
+
+    ConfigManager.Instance.init(options.id)
+
+    if (options.verbose === true) {
+      logger.level = 'debug'
+    }
+  })
+
+program.command('server')
+  .description('Run in server mode, to execute remote jobs of registered PeerTube instances')
+  .action(async () => {
+    try {
+      await RunnerServer.Instance.run()
+    } catch (err) {
+      logger.error(err, 'Cannot run PeerTube runner as server mode')
+      process.exit(-1)
+    }
+  })
+
+program.command('register')
+  .description('Register a new PeerTube instance to process runner jobs')
+  .requiredOption('--url <url>', 'PeerTube instance URL', parseUrl)
+  .requiredOption('--registration-token <token>', 'Runner registration token (can be found in PeerTube instance administration')
+  .requiredOption('--runner-name <name>', 'Runner name')
+  .option('--runner-description <description>', 'Runner description')
+  .action(async options => {
+    try {
+      await registerRunner(options)
+    } catch (err) {
+      console.error('Cannot register this PeerTube runner.')
+      console.error(err)
+      process.exit(-1)
+    }
+  })
+
+program.command('unregister')
+  .description('Unregister the runner from PeerTube instance')
+  .requiredOption('--url <url>', 'PeerTube instance URL', parseUrl)
+  .requiredOption('--runner-name <name>', 'Runner name')
+  .action(async options => {
+    try {
+      await unregisterRunner(options)
+    } catch (err) {
+      console.error('Cannot unregister this PeerTube runner.')
+      console.error(err)
+      process.exit(-1)
+    }
+  })
+
+program.command('list-registered')
+  .description('List registered PeerTube instances')
+  .action(async () => {
+    try {
+      await listRegistered()
+    } catch (err) {
+      console.error('Cannot list registered PeerTube instances.')
+      console.error(err)
+      process.exit(-1)
+    }
+  })
+
+program.parse()
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+function parseUrl (url: string) {
+  if (url.startsWith('http://') !== true && url.startsWith('https://') !== true) {
+    throw new InvalidArgumentError('URL should start with a http:// or https://')
+  }
+
+  return url
+}

+ 1 - 0
apps/peertube-runner/src/register/index.ts

@@ -0,0 +1 @@
+export * from './register.js'

+ 36 - 0
apps/peertube-runner/src/register/register.ts

@@ -0,0 +1,36 @@
+import { IPCClient } from '../shared/ipc/index.js'
+
+export async function registerRunner (options: {
+  url: string
+  registrationToken: string
+  runnerName: string
+  runnerDescription?: string
+}) {
+  const client = new IPCClient()
+  await client.run()
+
+  await client.askRegister(options)
+
+  client.stop()
+}
+
+export async function unregisterRunner (options: {
+  url: string
+  runnerName: string
+}) {
+  const client = new IPCClient()
+  await client.run()
+
+  await client.askUnregister(options)
+
+  client.stop()
+}
+
+export async function listRegistered () {
+  const client = new IPCClient()
+  await client.run()
+
+  await client.askListRegistered()
+
+  client.stop()
+}

+ 1 - 0
apps/peertube-runner/src/server/index.ts

@@ -0,0 +1 @@
+export * from './server.js'

+ 2 - 0
apps/peertube-runner/src/server/process/index.ts

@@ -0,0 +1,2 @@
+export * from './shared/index.js'
+export * from './process.js'

+ 34 - 0
apps/peertube-runner/src/server/process/process.ts

@@ -0,0 +1,34 @@
+import {
+  RunnerJobLiveRTMPHLSTranscodingPayload,
+  RunnerJobStudioTranscodingPayload,
+  RunnerJobVODAudioMergeTranscodingPayload,
+  RunnerJobVODHLSTranscodingPayload,
+  RunnerJobVODWebVideoTranscodingPayload
+} from '@peertube/peertube-models'
+import { logger } from '../../shared/index.js'
+import { processAudioMergeTranscoding, processHLSTranscoding, ProcessOptions, processWebVideoTranscoding } from './shared/index.js'
+import { ProcessLiveRTMPHLSTranscoding } from './shared/process-live.js'
+import { processStudioTranscoding } from './shared/process-studio.js'
+
+export async function processJob (options: ProcessOptions) {
+  const { server, job } = options
+
+  logger.info(`[${server.url}] Processing job of type ${job.type}: ${job.uuid}`, { payload: job.payload })
+
+  if (job.type === 'vod-audio-merge-transcoding') {
+    await processAudioMergeTranscoding(options as ProcessOptions<RunnerJobVODAudioMergeTranscodingPayload>)
+  } else if (job.type === 'vod-web-video-transcoding') {
+    await processWebVideoTranscoding(options as ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>)
+  } else if (job.type === 'vod-hls-transcoding') {
+    await processHLSTranscoding(options as ProcessOptions<RunnerJobVODHLSTranscodingPayload>)
+  } else if (job.type === 'live-rtmp-hls-transcoding') {
+    await new ProcessLiveRTMPHLSTranscoding(options as ProcessOptions<RunnerJobLiveRTMPHLSTranscodingPayload>).process()
+  } else if (job.type === 'video-studio-transcoding') {
+    await processStudioTranscoding(options as ProcessOptions<RunnerJobStudioTranscodingPayload>)
+  } else {
+    logger.error(`Unknown job ${job.type} to process`)
+    return
+  }
+
+  logger.info(`[${server.url}] Finished processing job of type ${job.type}: ${job.uuid}`)
+}

+ 106 - 0
apps/peertube-runner/src/server/process/shared/common.ts

@@ -0,0 +1,106 @@
+import { remove } from 'fs-extra/esm'
+import { join } from 'path'
+import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg'
+import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models'
+import { buildUUID } from '@peertube/peertube-node-utils'
+import { PeerTubeServer } from '@peertube/peertube-server-commands'
+import { ConfigManager, downloadFile, logger } from '../../../shared/index.js'
+import { getTranscodingLogger } from './transcoding-logger.js'
+
+export type JobWithToken <T extends RunnerJobPayload = RunnerJobPayload> = RunnerJob<T> & { jobToken: string }
+
+export type ProcessOptions <T extends RunnerJobPayload = RunnerJobPayload> = {
+  server: PeerTubeServer
+  job: JobWithToken<T>
+  runnerToken: string
+}
+
+export async function downloadInputFile (options: {
+  url: string
+  job: JobWithToken
+  runnerToken: string
+}) {
+  const { url, job, runnerToken } = options
+  const destination = join(ConfigManager.Instance.getTranscodingDirectory(), buildUUID())
+
+  try {
+    await downloadFile({ url, jobToken: job.jobToken, runnerToken, destination })
+  } catch (err) {
+    remove(destination)
+      .catch(err => logger.error({ err }, `Cannot remove ${destination}`))
+
+    throw err
+  }
+
+  return destination
+}
+
+export function scheduleTranscodingProgress (options: {
+  server: PeerTubeServer
+  runnerToken: string
+  job: JobWithToken
+  progressGetter: () => number
+}) {
+  const { job, server, progressGetter, runnerToken } = options
+
+  const updateInterval = ConfigManager.Instance.isTestInstance()
+    ? 500
+    : 60000
+
+  const update = () => {
+    server.runnerJobs.update({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken, progress: progressGetter() })
+      .catch(err => logger.error({ err }, 'Cannot send job progress'))
+  }
+
+  const interval = setInterval(() => {
+    update()
+  }, updateInterval)
+
+  update()
+
+  return interval
+}
+
+// ---------------------------------------------------------------------------
+
+export function buildFFmpegVOD (options: {
+  onJobProgress: (progress: number) => void
+}) {
+  const { onJobProgress } = options
+
+  return new FFmpegVOD({
+    ...getCommonFFmpegOptions(),
+
+    updateJobProgress: arg => {
+      const progress = arg < 0 || arg > 100
+        ? undefined
+        : arg
+
+      onJobProgress(progress)
+    }
+  })
+}
+
+export function buildFFmpegLive () {
+  return new FFmpegLive(getCommonFFmpegOptions())
+}
+
+export function buildFFmpegEdition () {
+  return new FFmpegEdition(getCommonFFmpegOptions())
+}
+
+function getCommonFFmpegOptions () {
+  const config = ConfigManager.Instance.getConfig()
+
+  return {
+    niceness: config.ffmpeg.nice,
+    threads: config.ffmpeg.threads,
+    tmpDirectory: ConfigManager.Instance.getTranscodingDirectory(),
+    profile: 'default',
+    availableEncoders: {
+      available: getDefaultAvailableEncoders(),
+      encodersToTry: getDefaultEncodersToTry()
+    },
+    logger: getTranscodingLogger()
+  }
+}

+ 3 - 0
apps/peertube-runner/src/server/process/shared/index.ts

@@ -0,0 +1,3 @@
+export * from './common.js'
+export * from './process-vod.js'
+export * from './transcoding-logger.js'

+ 338 - 0
apps/peertube-runner/src/server/process/shared/process-live.ts

@@ -0,0 +1,338 @@
+import { FSWatcher, watch } from 'chokidar'
+import { FfmpegCommand } from 'fluent-ffmpeg'
+import { ensureDir, remove } from 'fs-extra/esm'
+import { basename, join } from 'path'
+import { wait } from '@peertube/peertube-core-utils'
+import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '@peertube/peertube-ffmpeg'
+import {
+  LiveRTMPHLSTranscodingSuccess,
+  LiveRTMPHLSTranscodingUpdatePayload,
+  PeerTubeProblemDocument,
+  RunnerJobLiveRTMPHLSTranscodingPayload,
+  ServerErrorCode
+} from '@peertube/peertube-models'
+import { buildUUID } from '@peertube/peertube-node-utils'
+import { ConfigManager } from '../../../shared/config-manager.js'
+import { logger } from '../../../shared/index.js'
+import { buildFFmpegLive, ProcessOptions } from './common.js'
+
+export class ProcessLiveRTMPHLSTranscoding {
+
+  private readonly outputPath: string
+  private readonly fsWatchers: FSWatcher[] = []
+
+  // Playlist name -> chunks
+  private readonly pendingChunksPerPlaylist = new Map<string, string[]>()
+
+  private readonly playlistsCreated = new Set<string>()
+  private allPlaylistsCreated = false
+
+  private ffmpegCommand: FfmpegCommand
+
+  private ended = false
+  private errored = false
+
+  constructor (private readonly options: ProcessOptions<RunnerJobLiveRTMPHLSTranscodingPayload>) {
+    this.outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), buildUUID())
+
+    logger.debug(`Using ${this.outputPath} to process live rtmp hls transcoding job ${options.job.uuid}`)
+  }
+
+  process () {
+    const job = this.options.job
+    const payload = job.payload
+
+    return new Promise<void>(async (res, rej) => {
+      try {
+        await ensureDir(this.outputPath)
+
+        logger.info(`Probing ${payload.input.rtmpUrl}`)
+        const probe = await ffprobePromise(payload.input.rtmpUrl)
+        logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`)
+
+        const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe)
+        const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe)
+        const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe)
+
+        const m3u8Watcher = watch(this.outputPath + '/*.m3u8')
+        this.fsWatchers.push(m3u8Watcher)
+
+        const tsWatcher = watch(this.outputPath + '/*.ts')
+        this.fsWatchers.push(tsWatcher)
+
+        m3u8Watcher.on('change', p => {
+          logger.debug(`${p} m3u8 playlist changed`)
+        })
+
+        m3u8Watcher.on('add', p => {
+          this.playlistsCreated.add(p)
+
+          if (this.playlistsCreated.size === this.options.job.payload.output.toTranscode.length + 1) {
+            this.allPlaylistsCreated = true
+            logger.info('All m3u8 playlists are created.')
+          }
+        })
+
+        tsWatcher.on('add', async p => {
+          try {
+            await this.sendPendingChunks()
+          } catch (err) {
+            this.onUpdateError({ err, rej, res })
+          }
+
+          const playlistName = this.getPlaylistIdFromTS(p)
+
+          const pendingChunks = this.pendingChunksPerPlaylist.get(playlistName) || []
+          pendingChunks.push(p)
+
+          this.pendingChunksPerPlaylist.set(playlistName, pendingChunks)
+        })
+
+        tsWatcher.on('unlink', p => {
+          this.sendDeletedChunkUpdate(p)
+            .catch(err => this.onUpdateError({ err, rej, res }))
+        })
+
+        this.ffmpegCommand = await buildFFmpegLive().getLiveTranscodingCommand({
+          inputUrl: payload.input.rtmpUrl,
+
+          outPath: this.outputPath,
+          masterPlaylistName: 'master.m3u8',
+
+          segmentListSize: payload.output.segmentListSize,
+          segmentDuration: payload.output.segmentDuration,
+
+          toTranscode: payload.output.toTranscode,
+
+          bitrate,
+          ratio,
+
+          hasAudio
+        })
+
+        logger.info(`Running live transcoding for ${payload.input.rtmpUrl}`)
+
+        this.ffmpegCommand.on('error', (err, stdout, stderr) => {
+          this.onFFmpegError({ err, stdout, stderr })
+
+          res()
+        })
+
+        this.ffmpegCommand.on('end', () => {
+          this.onFFmpegEnded()
+            .catch(err => logger.error({ err }, 'Error in FFmpeg end handler'))
+
+          res()
+        })
+
+        this.ffmpegCommand.run()
+      } catch (err) {
+        rej(err)
+      }
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private onUpdateError (options: {
+    err: Error
+    res: () => void
+    rej: (reason?: any) => void
+  }) {
+    const { err, res, rej } = options
+
+    if (this.errored) return
+    if (this.ended) return
+
+    this.errored = true
+
+    this.ffmpegCommand.kill('SIGINT')
+
+    const type = ((err as any).res?.body as PeerTubeProblemDocument)?.code
+    if (type === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) {
+      logger.info({ err }, 'Stopping transcoding as the job is not in processing state anymore')
+
+      res()
+    } else {
+      logger.error({ err }, 'Cannot send update after added/deleted chunk, stopping live transcoding')
+
+      this.sendError(err)
+        .catch(subErr => logger.error({ err: subErr }, 'Cannot send error'))
+
+      rej(err)
+    }
+
+    this.cleanup()
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private onFFmpegError (options: {
+    err: any
+    stdout: string
+    stderr: string
+  }) {
+    const { err, stdout, stderr } = options
+
+    // Don't care that we killed the ffmpeg process
+    if (err?.message?.includes('Exiting normally')) return
+    if (this.errored) return
+    if (this.ended) return
+
+    this.errored = true
+
+    logger.error({ err, stdout, stderr }, 'FFmpeg transcoding error.')
+
+    this.sendError(err)
+      .catch(subErr => logger.error({ err: subErr }, 'Cannot send error'))
+
+    this.cleanup()
+  }
+
+  private async sendError (err: Error) {
+    await this.options.server.runnerJobs.error({
+      jobToken: this.options.job.jobToken,
+      jobUUID: this.options.job.uuid,
+      runnerToken: this.options.runnerToken,
+      message: err.message
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private async onFFmpegEnded () {
+    if (this.ended) return
+
+    this.ended = true
+    logger.info('FFmpeg ended, sending success to server')
+
+    // Wait last ffmpeg chunks generation
+    await wait(1500)
+
+    this.sendSuccess()
+      .catch(err => logger.error({ err }, 'Cannot send success'))
+
+    this.cleanup()
+  }
+
+  private async sendSuccess () {
+    const successBody: LiveRTMPHLSTranscodingSuccess = {}
+
+    await this.options.server.runnerJobs.success({
+      jobToken: this.options.job.jobToken,
+      jobUUID: this.options.job.uuid,
+      runnerToken: this.options.runnerToken,
+      payload: successBody
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private sendDeletedChunkUpdate (deletedChunk: string): Promise<any> {
+    if (this.ended) return Promise.resolve()
+
+    logger.debug(`Sending removed live chunk ${deletedChunk} update`)
+
+    const videoChunkFilename = basename(deletedChunk)
+
+    let payload: LiveRTMPHLSTranscodingUpdatePayload = {
+      type: 'remove-chunk',
+      videoChunkFilename
+    }
+
+    if (this.allPlaylistsCreated) {
+      const playlistName = this.getPlaylistName(videoChunkFilename)
+
+      payload = {
+        ...payload,
+        masterPlaylistFile: join(this.outputPath, 'master.m3u8'),
+        resolutionPlaylistFilename: playlistName,
+        resolutionPlaylistFile: join(this.outputPath, playlistName)
+      }
+    }
+
+    return this.updateWithRetry(payload)
+  }
+
+  private async sendPendingChunks (): Promise<any> {
+    if (this.ended) return Promise.resolve()
+
+    const promises: Promise<any>[] = []
+
+    for (const playlist of this.pendingChunksPerPlaylist.keys()) {
+      for (const chunk of this.pendingChunksPerPlaylist.get(playlist)) {
+        logger.debug(`Sending added live chunk ${chunk} update`)
+
+        const videoChunkFilename = basename(chunk)
+
+        let payload: LiveRTMPHLSTranscodingUpdatePayload = {
+          type: 'add-chunk',
+          videoChunkFilename,
+          videoChunkFile: chunk
+        }
+
+        if (this.allPlaylistsCreated) {
+          const playlistName = this.getPlaylistName(videoChunkFilename)
+
+          payload = {
+            ...payload,
+            masterPlaylistFile: join(this.outputPath, 'master.m3u8'),
+            resolutionPlaylistFilename: playlistName,
+            resolutionPlaylistFile: join(this.outputPath, playlistName)
+          }
+        }
+
+        promises.push(this.updateWithRetry(payload))
+      }
+
+      this.pendingChunksPerPlaylist.set(playlist, [])
+    }
+
+    await Promise.all(promises)
+  }
+
+  private async updateWithRetry (payload: LiveRTMPHLSTranscodingUpdatePayload, currentTry = 1): Promise<any> {
+    if (this.ended || this.errored) return
+
+    try {
+      await this.options.server.runnerJobs.update({
+        jobToken: this.options.job.jobToken,
+        jobUUID: this.options.job.uuid,
+        runnerToken: this.options.runnerToken,
+        payload
+      })
+    } catch (err) {
+      if (currentTry >= 3) throw err
+      if ((err.res?.body as PeerTubeProblemDocument)?.code === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) throw err
+
+      logger.warn({ err }, 'Will retry update after error')
+      await wait(250)
+
+      return this.updateWithRetry(payload, currentTry + 1)
+    }
+  }
+
+  private getPlaylistName (videoChunkFilename: string) {
+    return `${videoChunkFilename.split('-')[0]}.m3u8`
+  }
+
+  private getPlaylistIdFromTS (segmentPath: string) {
+    const playlistIdMatcher = /^([\d+])-/
+
+    return basename(segmentPath).match(playlistIdMatcher)[1]
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private cleanup () {
+    logger.debug(`Cleaning up job ${this.options.job.uuid}`)
+
+    for (const fsWatcher of this.fsWatchers) {
+      fsWatcher.close()
+        .catch(err => logger.error({ err }, 'Cannot close watcher'))
+    }
+
+    remove(this.outputPath)
+      .catch(err => logger.error({ err }, `Cannot remove ${this.outputPath}`))
+  }
+}

+ 165 - 0
apps/peertube-runner/src/server/process/shared/process-studio.ts

@@ -0,0 +1,165 @@
+import { remove } from 'fs-extra/esm'
+import { join } from 'path'
+import { pick } from '@peertube/peertube-core-utils'
+import {
+  RunnerJobStudioTranscodingPayload,
+  VideoStudioTask,
+  VideoStudioTaskCutPayload,
+  VideoStudioTaskIntroPayload,
+  VideoStudioTaskOutroPayload,
+  VideoStudioTaskPayload,
+  VideoStudioTaskWatermarkPayload,
+  VideoStudioTranscodingSuccess
+} from '@peertube/peertube-models'
+import { buildUUID } from '@peertube/peertube-node-utils'
+import { ConfigManager } from '../../../shared/config-manager.js'
+import { logger } from '../../../shared/index.js'
+import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions, scheduleTranscodingProgress } from './common.js'
+
+export async function processStudioTranscoding (options: ProcessOptions<RunnerJobStudioTranscodingPayload>) {
+  const { server, job, runnerToken } = options
+  const payload = job.payload
+
+  let inputPath: string
+  let outputPath: string
+  let tmpInputFilePath: string
+
+  let tasksProgress = 0
+
+  const updateProgressInterval = scheduleTranscodingProgress({
+    job,
+    server,
+    runnerToken,
+    progressGetter: () => tasksProgress
+  })
+
+  try {
+    logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`)
+
+    inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
+    tmpInputFilePath = inputPath
+
+    logger.info(`Input file ${payload.input.videoFileUrl} downloaded for job ${job.jobToken}. Running studio transcoding tasks.`)
+
+    for (const task of payload.tasks) {
+      const outputFilename = 'output-edition-' + buildUUID() + '.mp4'
+      outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename)
+
+      await processTask({
+        inputPath: tmpInputFilePath,
+        outputPath,
+        task,
+        job,
+        runnerToken
+      })
+
+      if (tmpInputFilePath) await remove(tmpInputFilePath)
+
+      // For the next iteration
+      tmpInputFilePath = outputPath
+
+      tasksProgress += Math.floor(100 / payload.tasks.length)
+    }
+
+    const successBody: VideoStudioTranscodingSuccess = {
+      videoFile: outputPath
+    }
+
+    await server.runnerJobs.success({
+      jobToken: job.jobToken,
+      jobUUID: job.uuid,
+      runnerToken,
+      payload: successBody
+    })
+  } finally {
+    if (tmpInputFilePath) await remove(tmpInputFilePath)
+    if (outputPath) await remove(outputPath)
+    if (updateProgressInterval) clearInterval(updateProgressInterval)
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
+  inputPath: string
+  outputPath: string
+  task: T
+  runnerToken: string
+  job: JobWithToken
+}
+
+const taskProcessors: { [id in VideoStudioTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
+  'add-intro': processAddIntroOutro,
+  'add-outro': processAddIntroOutro,
+  'cut': processCut,
+  'add-watermark': processAddWatermark
+}
+
+async function processTask (options: TaskProcessorOptions) {
+  const { task } = options
+
+  const processor = taskProcessors[options.task.name]
+  if (!process) throw new Error('Unknown task ' + task.name)
+
+  return processor(options)
+}
+
+async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
+  const { inputPath, task, runnerToken, job } = options
+
+  logger.debug('Adding intro/outro to ' + inputPath)
+
+  const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
+
+  try {
+    await buildFFmpegEdition().addIntroOutro({
+      ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+      introOutroPath,
+      type: task.name === 'add-intro'
+        ? 'intro'
+        : 'outro'
+    })
+  } finally {
+    await remove(introOutroPath)
+  }
+}
+
+function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
+  const { inputPath, task } = options
+
+  logger.debug(`Cutting ${inputPath}`)
+
+  return buildFFmpegEdition().cutVideo({
+    ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+    start: task.options.start,
+    end: task.options.end
+  })
+}
+
+async function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
+  const { inputPath, task, runnerToken, job } = options
+
+  logger.debug('Adding watermark to ' + inputPath)
+
+  const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
+
+  try {
+    await buildFFmpegEdition().addWatermark({
+      ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+      watermarkPath,
+
+      videoFilters: {
+        watermarkSizeRatio: task.options.watermarkSizeRatio,
+        horitonzalMarginRatio: task.options.horitonzalMarginRatio,
+        verticalMarginRatio: task.options.verticalMarginRatio
+      }
+    })
+  } finally {
+    await remove(watermarkPath)
+  }
+}

+ 201 - 0
apps/peertube-runner/src/server/process/shared/process-vod.ts

@@ -0,0 +1,201 @@
+import { remove } from 'fs-extra/esm'
+import { join } from 'path'
+import {
+  RunnerJobVODAudioMergeTranscodingPayload,
+  RunnerJobVODHLSTranscodingPayload,
+  RunnerJobVODWebVideoTranscodingPayload,
+  VODAudioMergeTranscodingSuccess,
+  VODHLSTranscodingSuccess,
+  VODWebVideoTranscodingSuccess
+} from '@peertube/peertube-models'
+import { buildUUID } from '@peertube/peertube-node-utils'
+import { ConfigManager } from '../../../shared/config-manager.js'
+import { logger } from '../../../shared/index.js'
+import { buildFFmpegVOD, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common.js'
+
+export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) {
+  const { server, job, runnerToken } = options
+
+  const payload = job.payload
+
+  let ffmpegProgress: number
+  let inputPath: string
+
+  const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
+
+  const updateProgressInterval = scheduleTranscodingProgress({
+    job,
+    server,
+    runnerToken,
+    progressGetter: () => ffmpegProgress
+  })
+
+  try {
+    logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`)
+
+    inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
+
+    logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`)
+
+    const ffmpegVod = buildFFmpegVOD({
+      onJobProgress: progress => { ffmpegProgress = progress }
+    })
+
+    await ffmpegVod.transcode({
+      type: 'video',
+
+      inputPath,
+
+      outputPath,
+
+      inputFileMutexReleaser: () => {},
+
+      resolution: payload.output.resolution,
+      fps: payload.output.fps
+    })
+
+    const successBody: VODWebVideoTranscodingSuccess = {
+      videoFile: outputPath
+    }
+
+    await server.runnerJobs.success({
+      jobToken: job.jobToken,
+      jobUUID: job.uuid,
+      runnerToken,
+      payload: successBody
+    })
+  } finally {
+    if (inputPath) await remove(inputPath)
+    if (outputPath) await remove(outputPath)
+    if (updateProgressInterval) clearInterval(updateProgressInterval)
+  }
+}
+
+export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVODHLSTranscodingPayload>) {
+  const { server, job, runnerToken } = options
+  const payload = job.payload
+
+  let ffmpegProgress: number
+  let inputPath: string
+
+  const uuid = buildUUID()
+  const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`)
+  const videoFilename = `${uuid}-${payload.output.resolution}-fragmented.mp4`
+  const videoPath = join(join(ConfigManager.Instance.getTranscodingDirectory(), videoFilename))
+
+  const updateProgressInterval = scheduleTranscodingProgress({
+    job,
+    server,
+    runnerToken,
+    progressGetter: () => ffmpegProgress
+  })
+
+  try {
+    logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`)
+
+    inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
+
+    logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`)
+
+    const ffmpegVod = buildFFmpegVOD({
+      onJobProgress: progress => { ffmpegProgress = progress }
+    })
+
+    await ffmpegVod.transcode({
+      type: 'hls',
+      copyCodecs: false,
+      inputPath,
+      hlsPlaylist: { videoFilename },
+      outputPath,
+
+      inputFileMutexReleaser: () => {},
+
+      resolution: payload.output.resolution,
+      fps: payload.output.fps
+    })
+
+    const successBody: VODHLSTranscodingSuccess = {
+      resolutionPlaylistFile: outputPath,
+      videoFile: videoPath
+    }
+
+    await server.runnerJobs.success({
+      jobToken: job.jobToken,
+      jobUUID: job.uuid,
+      runnerToken,
+      payload: successBody
+    })
+  } finally {
+    if (inputPath) await remove(inputPath)
+    if (outputPath) await remove(outputPath)
+    if (videoPath) await remove(videoPath)
+    if (updateProgressInterval) clearInterval(updateProgressInterval)
+  }
+}
+
+export async function processAudioMergeTranscoding (options: ProcessOptions<RunnerJobVODAudioMergeTranscodingPayload>) {
+  const { server, job, runnerToken } = options
+  const payload = job.payload
+
+  let ffmpegProgress: number
+  let audioPath: string
+  let inputPath: string
+
+  const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
+
+  const updateProgressInterval = scheduleTranscodingProgress({
+    job,
+    server,
+    runnerToken,
+    progressGetter: () => ffmpegProgress
+  })
+
+  try {
+    logger.info(
+      `Downloading input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
+      `for audio merge transcoding job ${job.jobToken}`
+    )
+
+    audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job })
+    inputPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job })
+
+    logger.info(
+      `Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
+      `for job ${job.jobToken}. Running audio merge transcoding.`
+    )
+
+    const ffmpegVod = buildFFmpegVOD({
+      onJobProgress: progress => { ffmpegProgress = progress }
+    })
+
+    await ffmpegVod.transcode({
+      type: 'merge-audio',
+
+      audioPath,
+      inputPath,
+
+      outputPath,
+
+      inputFileMutexReleaser: () => {},
+
+      resolution: payload.output.resolution,
+      fps: payload.output.fps
+    })
+
+    const successBody: VODAudioMergeTranscodingSuccess = {
+      videoFile: outputPath
+    }
+
+    await server.runnerJobs.success({
+      jobToken: job.jobToken,
+      jobUUID: job.uuid,
+      runnerToken,
+      payload: successBody
+    })
+  } finally {
+    if (audioPath) await remove(audioPath)
+    if (inputPath) await remove(inputPath)
+    if (outputPath) await remove(outputPath)
+    if (updateProgressInterval) clearInterval(updateProgressInterval)
+  }
+}

+ 10 - 0
apps/peertube-runner/src/server/process/shared/transcoding-logger.ts

@@ -0,0 +1,10 @@
+import { logger } from '../../../shared/index.js'
+
+export function getTranscodingLogger () {
+  return {
+    info: logger.info.bind(logger),
+    debug: logger.debug.bind(logger),
+    warn: logger.warn.bind(logger),
+    error: logger.error.bind(logger)
+  }
+}

+ 307 - 0
apps/peertube-runner/src/server/server.ts

@@ -0,0 +1,307 @@
+import { ensureDir, remove } from 'fs-extra/esm'
+import { readdir } from 'fs/promises'
+import { join } from 'path'
+import { io, Socket } from 'socket.io-client'
+import { pick, shuffle, wait } from '@peertube/peertube-core-utils'
+import { PeerTubeProblemDocument, ServerErrorCode } from '@peertube/peertube-models'
+import { PeerTubeServer as PeerTubeServerCommand } from '@peertube/peertube-server-commands'
+import { ConfigManager } from '../shared/index.js'
+import { IPCServer } from '../shared/ipc/index.js'
+import { logger } from '../shared/logger.js'
+import { JobWithToken, processJob } from './process/index.js'
+import { isJobSupported } from './shared/index.js'
+
+type PeerTubeServer = PeerTubeServerCommand & {
+  runnerToken: string
+  runnerName: string
+  runnerDescription?: string
+}
+
+export class RunnerServer {
+  private static instance: RunnerServer
+
+  private servers: PeerTubeServer[] = []
+  private processingJobs: { job: JobWithToken, server: PeerTubeServer }[] = []
+
+  private checkingAvailableJobs = false
+
+  private cleaningUp = false
+
+  private readonly sockets = new Map<PeerTubeServer, Socket>()
+
+  private constructor () {}
+
+  async run () {
+    logger.info('Running PeerTube runner in server mode')
+
+    await ConfigManager.Instance.load()
+
+    for (const registered of ConfigManager.Instance.getConfig().registeredInstances) {
+      const serverCommand = new PeerTubeServerCommand({ url: registered.url })
+
+      this.loadServer(Object.assign(serverCommand, registered))
+
+      logger.info(`Loading registered instance ${registered.url}`)
+    }
+
+    // Run IPC
+    const ipcServer = new IPCServer()
+    try {
+      await ipcServer.run(this)
+    } catch (err) {
+      logger.error('Cannot start local socket for IPC communication', err)
+      process.exit(-1)
+    }
+
+    // Cleanup on exit
+    for (const code of [ 'SIGINT', 'SIGUSR1', 'SIGUSR2', 'uncaughtException' ]) {
+      process.on(code, async (err, origin) => {
+        if (code === 'uncaughtException') {
+          logger.error({ err, origin }, 'uncaughtException')
+        }
+
+        await this.onExit()
+      })
+    }
+
+    // Process jobs
+    await ensureDir(ConfigManager.Instance.getTranscodingDirectory())
+    await this.cleanupTMP()
+
+    logger.info(`Using ${ConfigManager.Instance.getTranscodingDirectory()} for transcoding directory`)
+
+    await this.checkAvailableJobs()
+  }
+
+  // ---------------------------------------------------------------------------
+
+  async registerRunner (options: {
+    url: string
+    registrationToken: string
+    runnerName: string
+    runnerDescription?: string
+  }) {
+    const { url, registrationToken, runnerName, runnerDescription } = options
+
+    logger.info(`Registering runner ${runnerName} on ${url}...`)
+
+    const serverCommand = new PeerTubeServerCommand({ url })
+    const { runnerToken } = await serverCommand.runners.register({ name: runnerName, description: runnerDescription, registrationToken })
+
+    const server: PeerTubeServer = Object.assign(serverCommand, {
+      runnerToken,
+      runnerName,
+      runnerDescription
+    })
+
+    this.loadServer(server)
+    await this.saveRegisteredInstancesInConf()
+
+    logger.info(`Registered runner ${runnerName} on ${url}`)
+
+    await this.checkAvailableJobs()
+  }
+
+  private loadServer (server: PeerTubeServer) {
+    this.servers.push(server)
+
+    const url = server.url + '/runners'
+    const socket = io(url, {
+      auth: {
+        runnerToken: server.runnerToken
+      },
+      transports: [ 'websocket' ]
+    })
+
+    socket.on('connect_error', err => logger.warn({ err }, `Cannot connect to ${url} socket`))
+    socket.on('connect', () => logger.info(`Connected to ${url} socket`))
+    socket.on('available-jobs', () => this.checkAvailableJobs())
+
+    this.sockets.set(server, socket)
+  }
+
+  async unregisterRunner (options: {
+    url: string
+    runnerName: string
+  }) {
+    const { url, runnerName } = options
+
+    const server = this.servers.find(s => s.url === url && s.runnerName === runnerName)
+    if (!server) {
+      logger.error(`Unknown server ${url} - ${runnerName} to unregister`)
+      return
+    }
+
+    logger.info(`Unregistering runner ${runnerName} on ${url}...`)
+
+    try {
+      await server.runners.unregister({ runnerToken: server.runnerToken })
+    } catch (err) {
+      logger.error({ err }, `Cannot unregister runner ${runnerName} on ${url}`)
+    }
+
+    this.unloadServer(server)
+    await this.saveRegisteredInstancesInConf()
+
+    logger.info(`Unregistered runner ${runnerName} on ${url}`)
+  }
+
+  private unloadServer (server: PeerTubeServer) {
+    this.servers = this.servers.filter(s => s !== server)
+
+    const socket = this.sockets.get(server)
+    socket.disconnect()
+
+    this.sockets.delete(server)
+  }
+
+  listRegistered () {
+    return {
+      servers: this.servers.map(s => {
+        return {
+          url: s.url,
+          runnerName: s.runnerName,
+          runnerDescription: s.runnerDescription
+        }
+      })
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private async checkAvailableJobs () {
+    if (this.checkingAvailableJobs) return
+
+    this.checkingAvailableJobs = true
+
+    let hadAvailableJob = false
+
+    for (const server of shuffle([ ...this.servers ])) {
+      try {
+        logger.info('Checking available jobs on ' + server.url)
+
+        const job = await this.requestJob(server)
+        if (!job) continue
+
+        hadAvailableJob = true
+
+        await this.tryToExecuteJobAsync(server, job)
+      } catch (err) {
+        const code = (err.res?.body as PeerTubeProblemDocument)?.code
+
+        if (code === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) {
+          logger.debug({ err }, 'Runner job is not in processing state anymore, retry later')
+          return
+        }
+
+        if (code === ServerErrorCode.UNKNOWN_RUNNER_TOKEN) {
+          logger.error({ err }, `Unregistering ${server.url} as the runner token ${server.runnerToken} is invalid`)
+
+          await this.unregisterRunner({ url: server.url, runnerName: server.runnerName })
+          return
+        }
+
+        logger.error({ err }, `Cannot request/accept job on ${server.url} for runner ${server.runnerName}`)
+      }
+    }
+
+    this.checkingAvailableJobs = false
+
+    if (hadAvailableJob && this.canProcessMoreJobs()) {
+      await wait(2500)
+
+      this.checkAvailableJobs()
+        .catch(err => logger.error({ err }, 'Cannot check more available jobs'))
+    }
+  }
+
+  private async requestJob (server: PeerTubeServer) {
+    logger.debug(`Requesting jobs on ${server.url} for runner ${server.runnerName}`)
+
+    const { availableJobs } = await server.runnerJobs.request({ runnerToken: server.runnerToken })
+
+    const filtered = availableJobs.filter(j => isJobSupported(j))
+
+    if (filtered.length === 0) {
+      logger.debug(`No job available on ${server.url} for runner ${server.runnerName}`)
+      return undefined
+    }
+
+    return filtered[0]
+  }
+
+  private async tryToExecuteJobAsync (server: PeerTubeServer, jobToAccept: { uuid: string }) {
+    if (!this.canProcessMoreJobs()) return
+
+    const { job } = await server.runnerJobs.accept({ runnerToken: server.runnerToken, jobUUID: jobToAccept.uuid })
+
+    const processingJob = { job, server }
+    this.processingJobs.push(processingJob)
+
+    processJob({ server, job, runnerToken: server.runnerToken })
+      .catch(err => {
+        logger.error({ err }, 'Cannot process job')
+
+        server.runnerJobs.error({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken: server.runnerToken, message: err.message })
+          .catch(err2 => logger.error({ err: err2 }, 'Cannot abort job after error'))
+      })
+      .finally(() => {
+        this.processingJobs = this.processingJobs.filter(p => p !== processingJob)
+
+        return this.checkAvailableJobs()
+      })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private saveRegisteredInstancesInConf () {
+    const data = this.servers.map(s => {
+      return pick(s, [ 'url', 'runnerToken', 'runnerName', 'runnerDescription' ])
+    })
+
+    return ConfigManager.Instance.setRegisteredInstances(data)
+  }
+
+  private canProcessMoreJobs () {
+    return this.processingJobs.length < ConfigManager.Instance.getConfig().jobs.concurrency
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private async cleanupTMP () {
+    const files = await readdir(ConfigManager.Instance.getTranscodingDirectory())
+
+    for (const file of files) {
+      await remove(join(ConfigManager.Instance.getTranscodingDirectory(), file))
+    }
+  }
+
+  private async onExit () {
+    if (this.cleaningUp) return
+    this.cleaningUp = true
+
+    logger.info('Cleaning up after program exit')
+
+    try {
+      for (const { server, job } of this.processingJobs) {
+        await server.runnerJobs.abort({
+          jobToken: job.jobToken,
+          jobUUID: job.uuid,
+          reason: 'Runner stopped',
+          runnerToken: server.runnerToken
+        })
+      }
+
+      await this.cleanupTMP()
+    } catch (err) {
+      logger.error(err)
+      process.exit(-1)
+    }
+
+    process.exit()
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}

+ 1 - 0
apps/peertube-runner/src/server/shared/index.ts

@@ -0,0 +1 @@
+export * from './supported-job.js'

+ 43 - 0
apps/peertube-runner/src/server/shared/supported-job.ts

@@ -0,0 +1,43 @@
+import {
+  RunnerJobLiveRTMPHLSTranscodingPayload,
+  RunnerJobPayload,
+  RunnerJobType,
+  RunnerJobStudioTranscodingPayload,
+  RunnerJobVODAudioMergeTranscodingPayload,
+  RunnerJobVODHLSTranscodingPayload,
+  RunnerJobVODWebVideoTranscodingPayload,
+  VideoStudioTaskPayload
+} from '@peertube/peertube-models'
+
+const supportedMatrix = {
+  'vod-web-video-transcoding': (_payload: RunnerJobVODWebVideoTranscodingPayload) => {
+    return true
+  },
+  'vod-hls-transcoding': (_payload: RunnerJobVODHLSTranscodingPayload) => {
+    return true
+  },
+  'vod-audio-merge-transcoding': (_payload: RunnerJobVODAudioMergeTranscodingPayload) => {
+    return true
+  },
+  'live-rtmp-hls-transcoding': (_payload: RunnerJobLiveRTMPHLSTranscodingPayload) => {
+    return true
+  },
+  'video-studio-transcoding': (payload: RunnerJobStudioTranscodingPayload) => {
+    const tasks = payload?.tasks
+    const supported = new Set<VideoStudioTaskPayload['name']>([ 'add-intro', 'add-outro', 'add-watermark', 'cut' ])
+
+    if (!Array.isArray(tasks)) return false
+
+    return tasks.every(t => t && supported.has(t.name))
+  }
+}
+
+export function isJobSupported (job: {
+  type: RunnerJobType
+  payload: RunnerJobPayload
+}) {
+  const fn = supportedMatrix[job.type]
+  if (!fn) return false
+
+  return fn(job.payload as any)
+}

+ 140 - 0
apps/peertube-runner/src/shared/config-manager.ts

@@ -0,0 +1,140 @@
+import { parse, stringify } from '@iarna/toml'
+import envPaths from 'env-paths'
+import { ensureDir, pathExists, remove } from 'fs-extra/esm'
+import { readFile, writeFile } from 'fs/promises'
+import merge from 'lodash-es/merge.js'
+import { dirname, join } from 'path'
+import { logger } from '../shared/index.js'
+
+const paths = envPaths('peertube-runner')
+
+type Config = {
+  jobs: {
+    concurrency: number
+  }
+
+  ffmpeg: {
+    threads: number
+    nice: number
+  }
+
+  registeredInstances: {
+    url: string
+    runnerToken: string
+    runnerName: string
+    runnerDescription?: string
+  }[]
+}
+
+export class ConfigManager {
+  private static instance: ConfigManager
+
+  private config: Config = {
+    jobs: {
+      concurrency: 2
+    },
+    ffmpeg: {
+      threads: 2,
+      nice: 20
+    },
+    registeredInstances: []
+  }
+
+  private id: string
+  private configFilePath: string
+
+  private constructor () {}
+
+  init (id: string) {
+    this.id = id
+    this.configFilePath = join(this.getConfigDir(), 'config.toml')
+  }
+
+  async load () {
+    logger.info(`Using ${this.configFilePath} as configuration file`)
+
+    if (this.isTestInstance()) {
+      logger.info('Removing configuration file as we are using the "test" id')
+      await remove(this.configFilePath)
+    }
+
+    await ensureDir(dirname(this.configFilePath))
+
+    if (!await pathExists(this.configFilePath)) {
+      await this.save()
+    }
+
+    const file = await readFile(this.configFilePath, 'utf-8')
+
+    this.config = merge(this.config, parse(file))
+  }
+
+  save () {
+    return writeFile(this.configFilePath, stringify(this.config))
+  }
+
+  // ---------------------------------------------------------------------------
+
+  async setRegisteredInstances (registeredInstances: {
+    url: string
+    runnerToken: string
+    runnerName: string
+    runnerDescription?: string
+  }[]) {
+    this.config.registeredInstances = registeredInstances
+
+    await this.save()
+  }
+
+  // ---------------------------------------------------------------------------
+
+  getConfig () {
+    return this.deepFreeze(this.config)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  getTranscodingDirectory () {
+    return join(paths.cache, this.id, 'transcoding')
+  }
+
+  getSocketDirectory () {
+    return join(paths.data, this.id)
+  }
+
+  getSocketPath () {
+    return join(this.getSocketDirectory(), 'peertube-runner.sock')
+  }
+
+  getConfigDir () {
+    return join(paths.config, this.id)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  isTestInstance () {
+    return typeof this.id === 'string' && this.id.match(/^test-\d$/)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  // Thanks: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
+  private deepFreeze <T extends object> (object: T) {
+    const propNames = Reflect.ownKeys(object)
+
+    // Freeze properties before freezing self
+    for (const name of propNames) {
+      const value = object[name]
+
+      if ((value && typeof value === 'object') || typeof value === 'function') {
+        this.deepFreeze(value)
+      }
+    }
+
+    return Object.freeze({ ...object })
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}

+ 67 - 0
apps/peertube-runner/src/shared/http.ts

@@ -0,0 +1,67 @@
+import { createWriteStream } from 'fs'
+import { remove } from 'fs-extra/esm'
+import { request as requestHTTP } from 'http'
+import { request as requestHTTPS, RequestOptions } from 'https'
+import { logger } from './logger.js'
+
+export function downloadFile (options: {
+  url: string
+  destination: string
+  runnerToken: string
+  jobToken: string
+}) {
+  const { url, destination, runnerToken, jobToken } = options
+
+  logger.debug(`Downloading file ${url}`)
+
+  return new Promise<void>((res, rej) => {
+    const parsed = new URL(url)
+
+    const body = JSON.stringify({
+      runnerToken,
+      jobToken
+    })
+
+    const getOptions: RequestOptions = {
+      method: 'POST',
+      hostname: parsed.hostname,
+      port: parsed.port,
+      path: parsed.pathname,
+      headers: {
+        'Content-Type': 'application/json',
+        'Content-Length': Buffer.byteLength(body, 'utf-8')
+      }
+    }
+
+    const request = getRequest(url)(getOptions, response => {
+      const code = response.statusCode ?? 0
+
+      if (code >= 400) {
+        return rej(new Error(response.statusMessage))
+      }
+
+      const file = createWriteStream(destination)
+      file.on('finish', () => res())
+
+      response.pipe(file)
+    })
+
+    request.on('error', err => {
+      remove(destination)
+        .catch(err => logger.error(err))
+
+      return rej(err)
+    })
+
+    request.write(body)
+    request.end()
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+function getRequest (url: string) {
+  if (url.startsWith('https://')) return requestHTTPS
+
+  return requestHTTP
+}

+ 3 - 0
apps/peertube-runner/src/shared/index.ts

@@ -0,0 +1,3 @@
+export * from './config-manager.js'
+export * from './http.js'
+export * from './logger.js'

+ 2 - 0
apps/peertube-runner/src/shared/ipc/index.ts

@@ -0,0 +1,2 @@
+export * from './ipc-client.js'
+export * from './ipc-server.js'

+ 88 - 0
apps/peertube-runner/src/shared/ipc/ipc-client.ts

@@ -0,0 +1,88 @@
+import CliTable3 from 'cli-table3'
+import { ensureDir } from 'fs-extra/esm'
+import { Client as NetIPC } from 'net-ipc'
+import { ConfigManager } from '../config-manager.js'
+import { IPCReponse, IPCReponseData, IPCRequest } from './shared/index.js'
+
+export class IPCClient {
+  private netIPC: NetIPC
+
+  async run () {
+    await ensureDir(ConfigManager.Instance.getSocketDirectory())
+
+    const socketPath = ConfigManager.Instance.getSocketPath()
+
+    this.netIPC = new NetIPC({ path: socketPath })
+
+    try {
+      await this.netIPC.connect()
+    } catch (err) {
+      if (err.code === 'ECONNREFUSED') {
+        throw new Error(
+          'This runner is not currently running in server mode on this system. ' +
+          'Please run it using the `server` command first (in another terminal for example) and then retry your command.'
+        )
+      }
+
+      throw err
+    }
+  }
+
+  async askRegister (options: {
+    url: string
+    registrationToken: string
+    runnerName: string
+    runnerDescription?: string
+  }) {
+    const req: IPCRequest = {
+      type: 'register',
+      ...options
+    }
+
+    const { success, error } = await this.netIPC.request(req) as IPCReponse
+
+    if (success) console.log('PeerTube instance registered')
+    else console.error('Could not register PeerTube instance on runner server side', error)
+  }
+
+  async askUnregister (options: {
+    url: string
+    runnerName: string
+  }) {
+    const req: IPCRequest = {
+      type: 'unregister',
+      ...options
+    }
+
+    const { success, error } = await this.netIPC.request(req) as IPCReponse
+
+    if (success) console.log('PeerTube instance unregistered')
+    else console.error('Could not unregister PeerTube instance on runner server side', error)
+  }
+
+  async askListRegistered () {
+    const req: IPCRequest = {
+      type: 'list-registered'
+    }
+
+    const { success, error, data } = await this.netIPC.request(req) as IPCReponse<IPCReponseData>
+    if (!success) {
+      console.error('Could not list registered PeerTube instances', error)
+      return
+    }
+
+    const table = new CliTable3({
+      head: [ 'instance', 'runner name', 'runner description' ]
+    })
+
+    for (const server of data.servers) {
+      table.push([ server.url, server.runnerName, server.runnerDescription ])
+    }
+
+    console.log(table.toString())
+  }
+
+  stop () {
+    this.netIPC.destroy()
+  }
+}

+ 61 - 0
apps/peertube-runner/src/shared/ipc/ipc-server.ts

@@ -0,0 +1,61 @@
+import { ensureDir } from 'fs-extra/esm'
+import { Server as NetIPC } from 'net-ipc'
+import { pick } from '@peertube/peertube-core-utils'
+import { RunnerServer } from '../../server/index.js'
+import { ConfigManager } from '../config-manager.js'
+import { logger } from '../logger.js'
+import { IPCReponse, IPCReponseData, IPCRequest } from './shared/index.js'
+
+export class IPCServer {
+  private netIPC: NetIPC
+  private runnerServer: RunnerServer
+
+  async run (runnerServer: RunnerServer) {
+    this.runnerServer = runnerServer
+
+    await ensureDir(ConfigManager.Instance.getSocketDirectory())
+
+    const socketPath = ConfigManager.Instance.getSocketPath()
+    this.netIPC = new NetIPC({ path: socketPath })
+    await this.netIPC.start()
+
+    logger.info(`IPC socket created on ${socketPath}`)
+
+    this.netIPC.on('request', async (req: IPCRequest, res) => {
+      try {
+        const data = await this.process(req)
+
+        this.sendReponse(res, { success: true, data })
+      } catch (err) {
+        logger.error('Cannot execute RPC call', err)
+        this.sendReponse(res, { success: false, error: err.message })
+      }
+    })
+  }
+
+  private async process (req: IPCRequest) {
+    switch (req.type) {
+      case 'register':
+        await this.runnerServer.registerRunner(pick(req, [ 'url', 'registrationToken', 'runnerName', 'runnerDescription' ]))
+        return undefined
+
+      case 'unregister':
+        await this.runnerServer.unregisterRunner(pick(req, [ 'url', 'runnerName' ]))
+        return undefined
+
+      case 'list-registered':
+        return Promise.resolve(this.runnerServer.listRegistered())
+
+      default:
+        throw new Error('Unknown RPC call ' + (req as any).type)
+    }
+  }
+
+  private sendReponse <T extends IPCReponseData> (
+    response: (data: any) => Promise<void>,
+    body: IPCReponse<T>
+  ) {
+    response(body)
+      .catch(err => logger.error('Cannot send response after IPC request', err))
+  }
+}

+ 2 - 0
apps/peertube-runner/src/shared/ipc/shared/index.ts

@@ -0,0 +1,2 @@
+export * from './ipc-request.model.js'
+export * from './ipc-response.model.js'

+ 0 - 0
packages/peertube-runner/shared/ipc/shared/ipc-request.model.ts → apps/peertube-runner/src/shared/ipc/shared/ipc-request.model.ts


+ 0 - 0
packages/peertube-runner/shared/ipc/shared/ipc-response.model.ts → apps/peertube-runner/src/shared/ipc/shared/ipc-response.model.ts


+ 12 - 0
apps/peertube-runner/src/shared/logger.ts

@@ -0,0 +1,12 @@
+import { pino } from 'pino'
+import pretty from 'pino-pretty'
+
+const logger = pino(pretty.default({
+  colorize: true
+}))
+
+logger.level = 'info'
+
+export {
+  logger
+}

+ 16 - 0
apps/peertube-runner/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": "./",
+    "outDir": "./dist",
+    "rootDir": "src",
+    "tsBuildInfoFile": "./dist/.tsbuildinfo"
+  },
+  "references": [
+    { "path": "../../packages/core-utils" },
+    { "path": "../../packages/ffmpeg" },
+    { "path": "../../packages/models" },
+    { "path": "../../packages/node-utils" },
+    { "path": "../../packages/server-commands" }
+  ]
+}

+ 0 - 0
packages/peertube-runner/yarn.lock → apps/peertube-runner/yarn.lock


+ 1 - 0
client/.eslintrc.json

@@ -14,6 +14,7 @@
         "project": [
           "tsconfig.eslint.json"
         ],
+        "EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true,
         "createDefaultProgram": false
       },
       "extends": [

+ 0 - 8
client/e2e/wdio.main.conf.ts

@@ -107,14 +107,6 @@ export const config = {
 
     tsNodeOpts: {
       project: require('path').join(__dirname, './tsconfig.json')
-    },
-
-    tsConfigPathsOpts: {
-      baseUrl: './',
-      paths: {
-        '@server/*': [ '../../server/*' ],
-        '@shared/*': [ '../../shared/*' ]
-      }
     }
   },
 

+ 8 - 2
client/package.json

@@ -14,7 +14,7 @@
   },
   "scripts": {
     "lint": "npm run lint-ts && npm run lint-scss",
-    "lint-ts": "eslint --ext .ts src/standalone/**/*.ts && npm run ng lint",
+    "lint-ts": "eslint --cache --ext .ts src/standalone/**/*.ts && npm run ng lint",
     "lint-scss": "stylelint 'src/**/*.scss'",
     "webpack": "webpack",
     "eslint": "eslint",
@@ -24,6 +24,9 @@
     "ngx-extractor": "ngx-extractor",
     "stylelint": "stylelint"
   },
+  "workspaces": [
+    "../packages/*"
+  ],
   "typings": "*.d.ts",
   "devDependencies": {
     "@angular-devkit/build-angular": "^16.0.2",
@@ -57,6 +60,8 @@
     "@peertube/maildev": "^1.2.0",
     "@peertube/p2p-media-loader-core": "^1.0.14",
     "@peertube/p2p-media-loader-hlsjs": "^1.0.14",
+    "@peertube/peertube-core-utils": "*",
+    "@peertube/peertube-models": "*",
     "@peertube/videojs-contextmenu": "^5.5.0",
     "@peertube/xliffmerge": "^2.0.3",
     "@popperjs/core": "^2.11.5",
@@ -86,7 +91,7 @@
     "buffer": "^6.0.3",
     "chart.js": "^4.3.0",
     "chartjs-plugin-zoom": "~2.0.1",
-    "chromedriver": "^113.0.0",
+    "chromedriver": "^115.0.1",
     "core-js": "^3.22.8",
     "css-loader": "^6.2.0",
     "debug": "^4.3.1",
@@ -122,6 +127,7 @@
     "stylelint": "^15.1.0",
     "stylelint-config-sass-guidelines": "^10.0.0",
     "ts-loader": "^9.3.0",
+    "ts-node": "^10.9.1",
     "tslib": "^2.4.0",
     "typescript": "~4.9.5",
     "video.js": "^7.19.2",

+ 1 - 1
client/src/app/+about/about-follows/about-follows.component.ts

@@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
 import { Component, OnInit } from '@angular/core'
 import { ComponentPagination, hasMoreItems, Notifier, RestService, ServerService } from '@app/core'
 import { InstanceFollowService } from '@app/shared/shared-instance'
-import { Actor } from '@shared/models/actors'
+import { Actor } from '@peertube/peertube-models'
 
 @Component({
   selector: 'my-about-follows',

+ 1 - 1
client/src/app/+about/about-instance/about-instance.component.ts

@@ -3,8 +3,8 @@ import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@ang
 import { ActivatedRoute } from '@angular/router'
 import { Notifier, ServerService } from '@app/core'
 import { AboutHTML } from '@app/shared/shared-instance'
+import { HTMLServerConfig, ServerStats } from '@peertube/peertube-models'
 import { copyToClipboard } from '@root-helpers/utils'
-import { HTMLServerConfig, ServerStats } from '@shared/models/server'
 import { ResolverData } from './about-instance.resolver'
 import { ContactAdminModalComponent } from './contact-admin-modal.component'
 

+ 1 - 1
client/src/app/+about/about-instance/about-instance.resolver.ts

@@ -4,7 +4,7 @@ import { Injectable } from '@angular/core'
 import { ServerService } from '@app/core'
 import { CustomMarkupService } from '@app/shared/shared-custom-markup'
 import { AboutHTML, InstanceService } from '@app/shared/shared-instance'
-import { About, ServerStats } from '@shared/models/server'
+import { About, ServerStats } from '@peertube/peertube-models'
 
 export type ResolverData = {
   serverStats: ServerStats

+ 1 - 1
client/src/app/+about/about-instance/contact-admin-modal.component.ts

@@ -11,7 +11,7 @@ import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { InstanceService } from '@app/shared/shared-instance'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { HTMLServerConfig, HttpStatusCode } from '@shared/models'
+import { HTMLServerConfig, HttpStatusCode } from '@peertube/peertube-models'
 
 type Prefill = {
   subject?: string

+ 1 - 1
client/src/app/+about/about-instance/instance-statistics.component.ts

@@ -1,5 +1,5 @@
 import { Component, Input } from '@angular/core'
-import { ServerStats } from '@shared/models/server'
+import { ServerStats } from '@peertube/peertube-models'
 
 @Component({
   selector: 'my-instance-statistics',

+ 1 - 1
client/src/app/+accounts/account-video-channels/account-video-channels.component.ts

@@ -5,7 +5,7 @@ import { ComponentPagination, hasMoreItems, MarkdownService, User, UserService }
 import { SimpleMemoize } from '@app/helpers'
 import { Account, AccountService, Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
 import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
-import { NSFWPolicyType, VideoSortField } from '@shared/models'
+import { NSFWPolicyType, VideoSortField } from '@peertube/peertube-models'
 
 @Component({
   selector: 'my-account-video-channels',

+ 1 - 1
client/src/app/+accounts/account-videos/account-videos.component.ts

@@ -4,7 +4,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
 import { Account, AccountService, VideoService } from '@app/shared/shared-main'
 import { VideoFilters } from '@app/shared/shared-video-miniature'
-import { VideoSortField } from '@shared/models'
+import { VideoSortField } from '@peertube/peertube-models'
 
 @Component({
   selector: 'my-account-videos',

+ 1 - 1
client/src/app/+accounts/accounts.component.ts

@@ -13,7 +13,7 @@ import {
   VideoService
 } from '@app/shared/shared-main'
 import { AccountReportComponent, BlocklistService } from '@app/shared/shared-moderation'
-import { HttpStatusCode, User, UserRight } from '@shared/models'
+import { HttpStatusCode, User, UserRight } from '@peertube/peertube-models'
 
 @Component({
   templateUrl: './accounts.component.html',

+ 1 - 1
client/src/app/+admin/admin.component.ts

@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'
 import { AuthService, ScreenService, ServerService } from '@app/core'
 import { ListOverflowItem } from '@app/shared/shared-main'
 import { TopMenuDropdownParam } from '@app/shared/shared-main/misc/top-menu-dropdown.component'
-import { UserRight } from '@shared/models'
+import { UserRight } from '@peertube/peertube-models'
 
 @Component({
   templateUrl: './admin.component.html',

+ 1 - 1
client/src/app/+admin/config/config.routes.ts

@@ -1,7 +1,7 @@
 import { Routes } from '@angular/router'
 import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config'
 import { UserRightGuard } from '@app/core'
-import { UserRight } from '@shared/models'
+import { UserRight } from '@peertube/peertube-models'
 
 export const ConfigRoutes: Routes = [
   {

+ 1 - 1
client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts

@@ -3,7 +3,7 @@ import { SelectOptionsItem } from 'src/types/select-options-item.model'
 import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
 import { FormGroup } from '@angular/forms'
 import { MenuService, ThemeService } from '@app/core'
-import { HTMLServerConfig } from '@shared/models'
+import { HTMLServerConfig } from '@peertube/peertube-models'
 import { ConfigService } from '../shared/config.service'
 
 @Component({

+ 1 - 1
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts

@@ -27,7 +27,7 @@ import {
 import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { CustomPageService } from '@app/shared/shared-main/custom-page'
-import { CustomConfig, CustomPage, HTMLServerConfig } from '@shared/models'
+import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
 import { EditConfigurationService } from './edit-configuration.service'
 
 type ComponentCustomConfig = CustomConfig & {

+ 1 - 1
client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts

@@ -2,7 +2,7 @@
 import { SelectOptionsItem } from 'src/types/select-options-item.model'
 import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
 import { FormGroup } from '@angular/forms'
-import { HTMLServerConfig } from '@shared/models'
+import { HTMLServerConfig } from '@peertube/peertube-models'
 import { ConfigService } from '../shared/config.service'
 import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
 

+ 1 - 1
client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts

@@ -2,7 +2,7 @@
 import { SelectOptionsItem } from 'src/types/select-options-item.model'
 import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
 import { FormGroup } from '@angular/forms'
-import { HTMLServerConfig } from '@shared/models'
+import { HTMLServerConfig } from '@peertube/peertube-models'
 import { ConfigService } from '../shared/config.service'
 import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
 

+ 1 - 1
client/src/app/+admin/config/shared/config.service.ts

@@ -2,7 +2,7 @@ import { catchError } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { RestExtractor } from '@app/core'
-import { CustomConfig } from '@shared/models'
+import { CustomConfig } from '@peertube/peertube-models'
 import { SelectOptionsItem } from '../../../../types/select-options-item.model'
 import { environment } from '../../../../environments/environment'
 

+ 1 - 1
client/src/app/+admin/follows/followers-list/followers-list.component.ts

@@ -5,7 +5,7 @@ import { formatICU } from '@app/helpers'
 import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { InstanceFollowService } from '@app/shared/shared-instance'
 import { DropdownAction } from '@app/shared/shared-main'
-import { ActorFollow } from '@shared/models'
+import { ActorFollow } from '@peertube/peertube-models'
 
 @Component({
   selector: 'my-followers-list',

+ 1 - 1
client/src/app/+admin/follows/following-list/following-list.component.ts

@@ -3,7 +3,7 @@ import { Component, OnInit, ViewChild } from '@angular/core'
 import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
 import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { InstanceFollowService } from '@app/shared/shared-instance'
-import { ActorFollow } from '@shared/models'
+import { ActorFollow } from '@peertube/peertube-models'
 import { FollowModalComponent } from './follow-modal.component'
 import { DropdownAction } from '@app/shared/shared-main'
 import { formatICU } from '@app/helpers'

+ 1 - 1
client/src/app/+admin/follows/follows.routes.ts

@@ -1,7 +1,7 @@
 import { Routes } from '@angular/router'
 import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
 import { UserRightGuard } from '@app/core'
-import { UserRight } from '@shared/models'
+import { UserRight } from '@peertube/peertube-models'
 import { FollowersListComponent } from './followers-list'
 import { FollowingListComponent } from './following-list/following-list.component'
 

+ 1 - 2
client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts

@@ -3,9 +3,8 @@ import { SortMeta } from 'primeng/api'
 import { Component, OnInit } from '@angular/core'
 import { ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
 import { BytesPipe, RedundancyService } from '@app/shared/shared-main'
+import { VideoRedundanciesTarget, VideoRedundancy, VideosRedundancyStats } from '@peertube/peertube-models'
 import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
-import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
-import { VideosRedundancyStats } from '@shared/models/server'
 
 @Component({
   selector: 'my-video-redundancies-list',

+ 1 - 1
client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts

@@ -1,5 +1,5 @@
 import { Component, Input } from '@angular/core'
-import { FileRedundancyInformation, StreamingPlaylistRedundancyInformation } from '@shared/models'
+import { FileRedundancyInformation, StreamingPlaylistRedundancyInformation } from '@peertube/peertube-models'
 
 @Component({
   selector: 'my-video-redundancy-information',

+ 1 - 1
client/src/app/+admin/moderation/moderation.routes.ts

@@ -3,7 +3,7 @@ import { AbuseListComponent } from '@app/+admin/moderation/abuse-list'
 import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
 import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
 import { UserRightGuard } from '@app/core'
-import { UserRight } from '@shared/models'
+import { UserRight } from '@peertube/peertube-models'
 import { RegistrationListComponent } from './registration-list'
 
 export const ModerationRoutes: Routes = [

+ 2 - 2
client/src/app/+admin/moderation/registration-list/admin-registration.service.ts

@@ -4,8 +4,8 @@ import { catchError, concatMap, toArray } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { RestExtractor, RestPagination, RestService } from '@app/core'
-import { arrayify } from '@shared/core-utils'
-import { ResultList, UserRegistration, UserRegistrationUpdateState } from '@shared/models'
+import { arrayify } from '@peertube/peertube-core-utils'
+import { ResultList, UserRegistration, UserRegistrationUpdateState } from '@peertube/peertube-models'
 import { environment } from '../../../../environments/environment'
 
 @Injectable()

+ 1 - 1
client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts

@@ -3,7 +3,7 @@ import { Notifier, ServerService } from '@app/core'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { UserRegistration } from '@shared/models'
+import { UserRegistration } from '@peertube/peertube-models'
 import { AdminRegistrationService } from './admin-registration.service'
 import { REGISTRATION_MODERATION_RESPONSE_VALIDATOR } from './process-registration-validators'
 

+ 1 - 1
client/src/app/+admin/moderation/registration-list/registration-list.component.ts

@@ -5,7 +5,7 @@ import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, S
 import { formatICU } from '@app/helpers'
 import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { DropdownAction } from '@app/shared/shared-main'
-import { UserRegistration, UserRegistrationState } from '@shared/models'
+import { UserRegistration, UserRegistrationState } from '@peertube/peertube-models'
 import { AdminRegistrationService } from './admin-registration.service'
 import { ProcessRegistrationModalComponent } from './process-registration-modal.component'
 

+ 3 - 3
client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts

@@ -7,9 +7,9 @@ import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, S
 import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { DropdownAction, VideoService } from '@app/shared/shared-main'
 import { VideoBlockService } from '@app/shared/shared-moderation'
+import { buildVideoEmbedLink, decorateVideoLink } from '@peertube/peertube-core-utils'
+import { VideoBlacklist, VideoBlacklistType, VideoBlacklistType_Type } from '@peertube/peertube-models'
 import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
-import { buildVideoEmbedLink, decorateVideoLink } from '@shared/core-utils'
-import { VideoBlacklist, VideoBlacklistType } from '@shared/models'
 
 @Component({
   selector: 'my-video-block-list',
@@ -21,7 +21,7 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
   totalRecords = 0
   sort: SortMeta = { field: 'createdAt', order: -1 }
   pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
-  blocklistTypeFilter: VideoBlacklistType = undefined
+  blocklistTypeFilter: VideoBlacklistType_Type
 
   videoBlocklistActions: DropdownAction<VideoBlacklist>[][] = []
 

+ 1 - 1
client/src/app/+admin/overview/comments/video-comment-list.component.ts

@@ -6,7 +6,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { DropdownAction } from '@app/shared/shared-main'
 import { BulkService } from '@app/shared/shared-moderation'
 import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment'
-import { FeedFormat, UserRight } from '@shared/models'
+import { FeedFormat, UserRight } from '@peertube/peertube-models'
 import { formatICU } from '@app/helpers'
 
 @Component({

+ 1 - 1
client/src/app/+admin/overview/comments/video-comment.routes.ts

@@ -1,6 +1,6 @@
 import { Routes } from '@angular/router'
 import { UserRightGuard } from '@app/core'
-import { UserRight } from '@shared/models'
+import { UserRight } from '@peertube/peertube-models'
 import { VideoCommentListComponent } from './video-comment-list.component'
 
 export const commentRoutes: Routes = [

+ 1 - 1
client/src/app/+admin/overview/users/user-edit/user-create.component.ts

@@ -14,7 +14,7 @@ import {
 } from '@app/shared/form-validators/user-validators'
 import { FormReactiveService } from '@app/shared/shared-forms'
 import { UserAdminService } from '@app/shared/shared-users'
-import { UserCreate, UserRole } from '@shared/models'
+import { UserCreate, UserRole } from '@peertube/peertube-models'
 import { UserEdit } from './user-edit'
 
 @Component({

+ 2 - 3
client/src/app/+admin/overview/users/user-edit/user-edit.ts

@@ -2,9 +2,8 @@ import { Directive, OnInit } from '@angular/core'
 import { ConfigService } from '@app/+admin/config/shared/config.service'
 import { AuthService, ScreenService, ServerService, User } from '@app/core'
 import { FormReactive } from '@app/shared/shared-forms'
-import { peertubeTranslate } from '@shared/core-utils'
-import { USER_ROLE_LABELS } from '@shared/core-utils/users'
-import { HTMLServerConfig, UserAdminFlag, UserRole } from '@shared/models'
+import { peertubeTranslate, USER_ROLE_LABELS } from '@peertube/peertube-core-utils'
+import { HTMLServerConfig, UserAdminFlag, UserRole } from '@peertube/peertube-models'
 import { SelectOptionsItem } from '../../../../../types/select-options-item.model'
 
 @Directive()

+ 1 - 1
client/src/app/+admin/overview/users/user-edit/user-password.component.ts

@@ -3,7 +3,7 @@ import { Notifier } from '@app/core'
 import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { UserAdminService } from '@app/shared/shared-users'
-import { UserUpdate } from '@shared/models'
+import { UserUpdate } from '@peertube/peertube-models'
 
 @Component({
   selector: 'my-user-password',

+ 1 - 1
client/src/app/+admin/overview/users/user-edit/user-update.component.ts

@@ -11,7 +11,7 @@ import {
 } from '@app/shared/form-validators/user-validators'
 import { FormReactiveService } from '@app/shared/shared-forms'
 import { TwoFactorService, UserAdminService } from '@app/shared/shared-users'
-import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@shared/models'
+import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@peertube/peertube-models'
 import { UserEdit } from './user-edit'
 
 @Component({

+ 2 - 2
client/src/app/+admin/overview/users/user-list/user-list.component.ts

@@ -7,8 +7,8 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { Actor, DropdownAction } from '@app/shared/shared-main'
 import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation'
 import { UserAdminService } from '@app/shared/shared-users'
+import { User, UserRole, UserRoleType } from '@peertube/peertube-models'
 import { logger } from '@root-helpers/logger'
-import { User, UserRole } from '@shared/models'
 
 type UserForList = User & {
   rawVideoQuota: number
@@ -166,7 +166,7 @@ export class UserListComponent extends RestTable <User> implements OnInit {
     return 'UserListComponent'
   }
 
-  getRoleClass (role: UserRole) {
+  getRoleClass (role: UserRoleType) {
     switch (role) {
       case UserRole.ADMINISTRATOR:
         return 'badge-purple'

+ 1 - 1
client/src/app/+admin/overview/users/users.routes.ts

@@ -1,6 +1,6 @@
 import { Routes } from '@angular/router'
 import { UserRightGuard } from '@app/core'
-import { UserRight } from '@shared/models'
+import { UserRight } from '@peertube/peertube-models'
 import { UserCreateComponent, UserUpdateComponent } from './user-edit'
 import { UserListComponent } from './user-list'
 

+ 2 - 2
client/src/app/+admin/overview/videos/video-admin.service.ts

@@ -5,8 +5,8 @@ import { Injectable } from '@angular/core'
 import { RestExtractor, RestPagination, RestService } from '@app/core'
 import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { CommonVideoParams, Video, VideoService } from '@app/shared/shared-main'
-import { ResultList, VideoInclude, VideoPrivacy } from '@shared/models'
-import { getAllPrivacies } from '@shared/core-utils'
+import { ResultList, VideoInclude, VideoPrivacy } from '@peertube/peertube-models'
+import { getAllPrivacies } from '@peertube/peertube-core-utils'
 
 @Injectable()
 export class VideoAdminService {

+ 2 - 2
client/src/app/+admin/overview/videos/video-list.component.ts

@@ -8,8 +8,8 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
 import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation'
 import { VideoActionsDisplayType } from '@app/shared/shared-video-miniature'
-import { getAllFiles } from '@shared/core-utils'
-import { UserRight, VideoFile, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@shared/models'
+import { getAllFiles } from '@peertube/peertube-core-utils'
+import { UserRight, VideoFile, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
 import { VideoAdminService } from './video-admin.service'
 
 @Component({

+ 1 - 1
client/src/app/+admin/overview/videos/video.routes.ts

@@ -1,6 +1,6 @@
 import { Routes } from '@angular/router'
 import { UserRightGuard } from '@app/core'
-import { UserRight } from '@shared/models'
+import { UserRight } from '@peertube/peertube-models'
 import { VideoListComponent } from './video-list.component'
 
 export const videosRoutes: Routes = [

+ 4 - 4
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts

@@ -4,8 +4,8 @@ import { ActivatedRoute, Router } from '@angular/router'
 import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
 import { ComponentPagination, ConfirmService, hasMoreItems, Notifier } from '@app/core'
 import { PluginService } from '@app/core/plugins/plugin.service'
-import { compareSemVer } from '@shared/core-utils'
-import { PeerTubePlugin, PluginType } from '@shared/models'
+import { compareSemVer } from '@peertube/peertube-core-utils'
+import { PeerTubePlugin, PluginType, PluginType_Type } from '@peertube/peertube-models'
 
 @Component({
   selector: 'my-plugin-list-installed',
@@ -13,7 +13,7 @@ import { PeerTubePlugin, PluginType } from '@shared/models'
   styleUrls: [ './plugin-list-installed.component.scss' ]
 })
 export class PluginListInstalledComponent implements OnInit {
-  pluginType: PluginType
+  pluginType: PluginType_Type
 
   pagination: ComponentPagination = {
     currentPage: 1,
@@ -48,7 +48,7 @@ export class PluginListInstalledComponent implements OnInit {
     this.route.queryParams.subscribe(query => {
       if (!query['pluginType']) return
 
-      this.pluginType = parseInt(query['pluginType'], 10)
+      this.pluginType = parseInt(query['pluginType'], 10) as PluginType_Type
 
       this.reloadPlugins()
     })

+ 3 - 3
client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts

@@ -4,8 +4,8 @@ import { Component, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
 import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PluginService } from '@app/core'
+import { PeerTubePluginIndex, PluginType, PluginType_Type } from '@peertube/peertube-models'
 import { logger } from '@root-helpers/logger'
-import { PeerTubePluginIndex, PluginType } from '@shared/models'
 
 @Component({
   selector: 'my-plugin-search',
@@ -13,7 +13,7 @@ import { PeerTubePluginIndex, PluginType } from '@shared/models'
   styleUrls: [ './plugin-search.component.scss' ]
 })
 export class PluginSearchComponent implements OnInit {
-  pluginType: PluginType
+  pluginType: PluginType_Type
 
   pagination: ComponentPagination = {
     currentPage: 1,
@@ -53,7 +53,7 @@ export class PluginSearchComponent implements OnInit {
     this.route.queryParams.subscribe(query => {
       if (!query['pluginType']) return
 
-      this.pluginType = parseInt(query['pluginType'], 10)
+      this.pluginType = parseInt(query['pluginType'], 10) as PluginType_Type
       this.search = query['search'] || ''
 
       this.reloadPlugins()

+ 1 - 1
client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts

@@ -5,7 +5,7 @@ import { ActivatedRoute } from '@angular/router'
 import { HooksService, Notifier, PluginService } from '@app/core'
 import { BuildFormArgument } from '@app/shared/form-validators'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
-import { PeerTubePlugin, RegisterServerSettingOptions } from '@shared/models'
+import { PeerTubePlugin, RegisterServerSettingOptions } from '@peertube/peertube-models'
 import { PluginApiService } from '../shared/plugin-api.service'
 
 @Component({

+ 1 - 1
client/src/app/+admin/plugins/plugins.routes.ts

@@ -3,7 +3,7 @@ import { PluginListInstalledComponent } from '@app/+admin/plugins/plugin-list-in
 import { PluginSearchComponent } from '@app/+admin/plugins/plugin-search/plugin-search.component'
 import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component'
 import { UserRightGuard } from '@app/core'
-import { UserRight } from '@shared/models'
+import { UserRight } from '@peertube/peertube-models'
 
 export const PluginsRoutes: Routes = [
   {

+ 10 - 9
client/src/app/+admin/plugins/shared/plugin-api.service.ts

@@ -9,9 +9,10 @@ import {
   PeerTubePlugin,
   PeerTubePluginIndex,
   PluginType,
+  PluginType_Type,
   RegisteredServerSettings,
   ResultList
-} from '@shared/models'
+} from '@peertube/peertube-models'
 import { environment } from '../../../../environments/environment'
 
 @Injectable()
@@ -25,7 +26,7 @@ export class PluginApiService {
     private pluginService: PluginService
   ) { }
 
-  getPluginTypeLabel (type: PluginType) {
+  getPluginTypeLabel (type: PluginType_Type) {
     if (type === PluginType.PLUGIN) {
       return $localize`plugin`
     }
@@ -34,7 +35,7 @@ export class PluginApiService {
   }
 
   getPlugins (
-    pluginType: PluginType,
+    pluginType: PluginType_Type,
     componentPagination: ComponentPagination,
     sort: string
   ) {
@@ -49,7 +50,7 @@ export class PluginApiService {
   }
 
   searchAvailablePlugins (
-    pluginType: PluginType,
+    pluginType: PluginType_Type,
     componentPagination: ComponentPagination,
     sort: string,
     search?: string
@@ -73,7 +74,7 @@ export class PluginApiService {
                .pipe(catchError(res => this.restExtractor.handleError(res)))
   }
 
-  getPluginRegisteredSettings (pluginName: string, pluginType: PluginType) {
+  getPluginRegisteredSettings (pluginName: string, pluginType: PluginType_Type) {
     const npmName = this.pluginService.nameToNpmName(pluginName, pluginType)
     const path = PluginApiService.BASE_PLUGIN_URL + '/' + npmName + '/registered-settings'
 
@@ -83,7 +84,7 @@ export class PluginApiService {
                )
   }
 
-  updatePluginSettings (pluginName: string, pluginType: PluginType, settings: any) {
+  updatePluginSettings (pluginName: string, pluginType: PluginType_Type, settings: any) {
     const npmName = this.pluginService.nameToNpmName(pluginName, pluginType)
     const path = PluginApiService.BASE_PLUGIN_URL + '/' + npmName + '/settings'
 
@@ -91,7 +92,7 @@ export class PluginApiService {
                .pipe(catchError(res => this.restExtractor.handleError(res)))
   }
 
-  uninstall (pluginName: string, pluginType: PluginType) {
+  uninstall (pluginName: string, pluginType: PluginType_Type) {
     const body: ManagePlugin = {
       npmName: this.pluginService.nameToNpmName(pluginName, pluginType)
     }
@@ -100,7 +101,7 @@ export class PluginApiService {
                .pipe(catchError(res => this.restExtractor.handleError(res)))
   }
 
-  update (pluginName: string, pluginType: PluginType) {
+  update (pluginName: string, pluginType: PluginType_Type) {
     const body: ManagePlugin = {
       npmName: this.pluginService.nameToNpmName(pluginName, pluginType)
     }
@@ -118,7 +119,7 @@ export class PluginApiService {
                .pipe(catchError(res => this.restExtractor.handleError(res)))
   }
 
-  getPluginOrThemeHref (type: PluginType, name: string) {
+  getPluginOrThemeHref (type: PluginType_Type, name: string) {
     const typeString = type === PluginType.PLUGIN
       ? 'plugin'
       : 'theme'

+ 2 - 2
client/src/app/+admin/plugins/shared/plugin-card.component.ts

@@ -1,5 +1,5 @@
 import { Component, Input } from '@angular/core'
-import { PeerTubePlugin, PeerTubePluginIndex, PluginType } from '@shared/models'
+import { PeerTubePlugin, PeerTubePluginIndex, PluginType_Type } from '@peertube/peertube-models'
 import { PluginApiService } from './plugin-api.service'
 
 @Component({
@@ -11,7 +11,7 @@ import { PluginApiService } from './plugin-api.service'
 export class PluginCardComponent {
   @Input() plugin: PeerTubePluginIndex | PeerTubePlugin
   @Input() version: string
-  @Input() pluginType: PluginType
+  @Input() pluginType: PluginType_Type
 
   constructor (
     private pluginApiService: PluginApiService

Some files were not shown because too many files changed in this diff