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)
pull/5917/head
Chocobozzz 2023-07-31 14:34:36 +02:00
parent 04d1da5621
commit 3a4992633e
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
3230 changed files with 162039 additions and 160923 deletions

View File

@ -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
}
}

View File

@ -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.

View File

@ -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

View File

@ -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: |

View File

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

View File

@ -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'

16
.gitignore vendored
View File

@ -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
.mocharc.cjs Normal file
View File

@ -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
}

View File

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

View File

@ -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)
```

View File

@ -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": {}
}

View File

@ -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)

View File

@ -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()

View File

@ -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)
}

View File

@ -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
}

View File

@ -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.')
}

View File

@ -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!')
}

View File

@ -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
}

View File

@ -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))

View File

@ -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
}

View File

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

View File

@ -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
apps/peertube-cli/yarn.lock Normal file
View File

@ -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"

View File

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

View File

@ -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)
```

View File

@ -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"
}
}

View File

@ -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)

View File

@ -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
}

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

@ -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}`)
}

View File

@ -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()
}
}

View File

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

View File

@ -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}`))
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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())
}
}

View File

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

View File

@ -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)
}

View File

@ -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())
}
}

View File

@ -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
}

View File

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

View File

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

View File

@ -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()
}
}

View File

@ -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))
}
}

View File

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

View File

@ -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
}

View File

@ -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" }
]
}

View File

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

View File

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

View File

@ -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",

View File

@ -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',

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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 = [
{

View File

@ -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({

View File

@ -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 & {

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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',

View File

@ -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 = [

View File

@ -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()

View File

@ -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'

View File

@ -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'

View File

@ -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>[][] = []

View File

@ -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({

View File

@ -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 = [

View File

@ -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({

View File

@ -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()

View File

@ -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',

View File

@ -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({

View File

@ -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'

View File

@ -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'

View File

@ -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 {

View File

@ -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({

View File

@ -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 = [

View File

@ -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()
})

View File

@ -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()

View File

@ -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({

View File

@ -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 = [
{

View File

@ -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'

View File

@ -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 have changed in this diff Show More