diff options
author | Chocobozzz <me@florianbigard.com> | 2023-08-17 08:59:21 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2023-08-17 08:59:21 +0200 |
commit | c380e3928517eb5311b38cf257816642617d7a33 (patch) | |
tree | 2ea9b70ebca16b5d109bcce98fe7f944dad89319 /apps/peertube-runner | |
parent | a8ca6190fb462bf6eb5685cfc1d8ae444164a487 (diff) | |
parent | 3a4992633ee62d5edfbb484d9c6bcb3cf158489d (diff) | |
download | PeerTube-c380e3928517eb5311b38cf257816642617d7a33.tar.gz PeerTube-c380e3928517eb5311b38cf257816642617d7a33.tar.zst PeerTube-c380e3928517eb5311b38cf257816642617d7a33.zip |
Merge branch 'feature/esm-and-nx' into develop
Diffstat (limited to 'apps/peertube-runner')
32 files changed, 2384 insertions, 0 deletions
diff --git a/apps/peertube-runner/.gitignore b/apps/peertube-runner/.gitignore new file mode 100644 index 000000000..6426ab063 --- /dev/null +++ b/apps/peertube-runner/.gitignore | |||
@@ -0,0 +1,3 @@ | |||
1 | node_modules | ||
2 | dist | ||
3 | meta.json | ||
diff --git a/apps/peertube-runner/.npmignore b/apps/peertube-runner/.npmignore new file mode 100644 index 000000000..af17b9f32 --- /dev/null +++ b/apps/peertube-runner/.npmignore | |||
@@ -0,0 +1,4 @@ | |||
1 | src | ||
2 | meta.json | ||
3 | tsconfig.json | ||
4 | scripts | ||
diff --git a/apps/peertube-runner/README.md b/apps/peertube-runner/README.md new file mode 100644 index 000000000..37760f867 --- /dev/null +++ b/apps/peertube-runner/README.md | |||
@@ -0,0 +1,43 @@ | |||
1 | # PeerTube runner | ||
2 | |||
3 | Runner program to execute jobs (transcoding...) of remote PeerTube instances. | ||
4 | |||
5 | Commands below has to be run at the root of PeerTube git repository. | ||
6 | |||
7 | ## Dev | ||
8 | |||
9 | ### Install dependencies | ||
10 | |||
11 | ```bash | ||
12 | cd peertube-root | ||
13 | yarn install --pure-lockfile | ||
14 | cd apps/peertube-runner && yarn install --pure-lockfile | ||
15 | ``` | ||
16 | |||
17 | ### Develop | ||
18 | |||
19 | ```bash | ||
20 | cd peertube-root | ||
21 | npm run dev:peertube-runner | ||
22 | ``` | ||
23 | |||
24 | ### Build | ||
25 | |||
26 | ```bash | ||
27 | cd peertube-root | ||
28 | npm run build:peertube-runner | ||
29 | ``` | ||
30 | |||
31 | ### Run | ||
32 | |||
33 | ```bash | ||
34 | cd peertube-root | ||
35 | node apps/peertube-runner/dist/peertube-runner.js --help | ||
36 | ``` | ||
37 | |||
38 | ### Publish on NPM | ||
39 | |||
40 | ```bash | ||
41 | cd peertube-root | ||
42 | (cd apps/peertube-runner && npm version patch) && npm run build:peertube-runner && (cd apps/peertube-runner && npm publish --access=public) | ||
43 | ``` | ||
diff --git a/apps/peertube-runner/package.json b/apps/peertube-runner/package.json new file mode 100644 index 000000000..1dca15451 --- /dev/null +++ b/apps/peertube-runner/package.json | |||
@@ -0,0 +1,20 @@ | |||
1 | { | ||
2 | "name": "@peertube/peertube-runner", | ||
3 | "version": "0.0.5", | ||
4 | "type": "module", | ||
5 | "main": "dist/peertube-runner.js", | ||
6 | "bin": "dist/peertube-runner.js", | ||
7 | "engines": { | ||
8 | "node": ">=16.x" | ||
9 | }, | ||
10 | "license": "AGPL-3.0", | ||
11 | "dependencies": {}, | ||
12 | "devDependencies": { | ||
13 | "@commander-js/extra-typings": "^10.0.3", | ||
14 | "@iarna/toml": "^2.2.5", | ||
15 | "env-paths": "^3.0.0", | ||
16 | "net-ipc": "^2.0.1", | ||
17 | "pino": "^8.11.0", | ||
18 | "pino-pretty": "^10.0.0" | ||
19 | } | ||
20 | } | ||
diff --git a/apps/peertube-runner/scripts/build.js b/apps/peertube-runner/scripts/build.js new file mode 100644 index 000000000..f54ca35f3 --- /dev/null +++ b/apps/peertube-runner/scripts/build.js | |||
@@ -0,0 +1,26 @@ | |||
1 | import * as esbuild from 'esbuild' | ||
2 | |||
3 | const packageJSON = JSON.parse(readFileSync(new URL('../package.json', import.meta.url))) | ||
4 | |||
5 | export const esbuildOptions = { | ||
6 | entryPoints: [ './src/peertube-runner.ts' ], | ||
7 | bundle: true, | ||
8 | platform: 'node', | ||
9 | format: 'esm', | ||
10 | target: 'node16', | ||
11 | external: [ | ||
12 | './lib-cov/fluent-ffmpeg', | ||
13 | 'pg-hstore' | ||
14 | ], | ||
15 | outfile: './dist/peertube-runner.js', | ||
16 | banner: { | ||
17 | js: `const require = (await import("node:module")).createRequire(import.meta.url);` + | ||
18 | `const __filename = (await import("node:url")).fileURLToPath(import.meta.url);` + | ||
19 | `const __dirname = (await import("node:path")).dirname(__filename);` | ||
20 | }, | ||
21 | define: { | ||
22 | 'process.env.PACKAGE_VERSION': `'${packageJSON.version}'` | ||
23 | } | ||
24 | } | ||
25 | |||
26 | await esbuild.build(esbuildOptions) | ||
diff --git a/apps/peertube-runner/src/peertube-runner.ts b/apps/peertube-runner/src/peertube-runner.ts new file mode 100644 index 000000000..67ca0e0ac --- /dev/null +++ b/apps/peertube-runner/src/peertube-runner.ts | |||
@@ -0,0 +1,91 @@ | |||
1 | #!/usr/bin/env node | ||
2 | |||
3 | import { Command, InvalidArgumentError } from '@commander-js/extra-typings' | ||
4 | import { listRegistered, registerRunner, unregisterRunner } from './register/index.js' | ||
5 | import { RunnerServer } from './server/index.js' | ||
6 | import { ConfigManager, logger } from './shared/index.js' | ||
7 | |||
8 | const program = new Command() | ||
9 | .version(process.env.PACKAGE_VERSION) | ||
10 | .option( | ||
11 | '--id <id>', | ||
12 | 'Runner server id, so you can run multiple PeerTube server runners with different configurations on the same machine', | ||
13 | 'default' | ||
14 | ) | ||
15 | .option('--verbose', 'Run in verbose mode') | ||
16 | .hook('preAction', thisCommand => { | ||
17 | const options = thisCommand.opts() | ||
18 | |||
19 | ConfigManager.Instance.init(options.id) | ||
20 | |||
21 | if (options.verbose === true) { | ||
22 | logger.level = 'debug' | ||
23 | } | ||
24 | }) | ||
25 | |||
26 | program.command('server') | ||
27 | .description('Run in server mode, to execute remote jobs of registered PeerTube instances') | ||
28 | .action(async () => { | ||
29 | try { | ||
30 | await RunnerServer.Instance.run() | ||
31 | } catch (err) { | ||
32 | logger.error(err, 'Cannot run PeerTube runner as server mode') | ||
33 | process.exit(-1) | ||
34 | } | ||
35 | }) | ||
36 | |||
37 | program.command('register') | ||
38 | .description('Register a new PeerTube instance to process runner jobs') | ||
39 | .requiredOption('--url <url>', 'PeerTube instance URL', parseUrl) | ||
40 | .requiredOption('--registration-token <token>', 'Runner registration token (can be found in PeerTube instance administration') | ||
41 | .requiredOption('--runner-name <name>', 'Runner name') | ||
42 | .option('--runner-description <description>', 'Runner description') | ||
43 | .action(async options => { | ||
44 | try { | ||
45 | await registerRunner(options) | ||
46 | } catch (err) { | ||
47 | console.error('Cannot register this PeerTube runner.') | ||
48 | console.error(err) | ||
49 | process.exit(-1) | ||
50 | } | ||
51 | }) | ||
52 | |||
53 | program.command('unregister') | ||
54 | .description('Unregister the runner from PeerTube instance') | ||
55 | .requiredOption('--url <url>', 'PeerTube instance URL', parseUrl) | ||
56 | .requiredOption('--runner-name <name>', 'Runner name') | ||
57 | .action(async options => { | ||
58 | try { | ||
59 | await unregisterRunner(options) | ||
60 | } catch (err) { | ||
61 | console.error('Cannot unregister this PeerTube runner.') | ||
62 | console.error(err) | ||
63 | process.exit(-1) | ||
64 | } | ||
65 | }) | ||
66 | |||
67 | program.command('list-registered') | ||
68 | .description('List registered PeerTube instances') | ||
69 | .action(async () => { | ||
70 | try { | ||
71 | await listRegistered() | ||
72 | } catch (err) { | ||
73 | console.error('Cannot list registered PeerTube instances.') | ||
74 | console.error(err) | ||
75 | process.exit(-1) | ||
76 | } | ||
77 | }) | ||
78 | |||
79 | program.parse() | ||
80 | |||
81 | // --------------------------------------------------------------------------- | ||
82 | // Private | ||
83 | // --------------------------------------------------------------------------- | ||
84 | |||
85 | function parseUrl (url: string) { | ||
86 | if (url.startsWith('http://') !== true && url.startsWith('https://') !== true) { | ||
87 | throw new InvalidArgumentError('URL should start with a http:// or https://') | ||
88 | } | ||
89 | |||
90 | return url | ||
91 | } | ||
diff --git a/apps/peertube-runner/src/register/index.ts b/apps/peertube-runner/src/register/index.ts new file mode 100644 index 000000000..a7d6cf457 --- /dev/null +++ b/apps/peertube-runner/src/register/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './register.js' | |||
diff --git a/apps/peertube-runner/src/register/register.ts b/apps/peertube-runner/src/register/register.ts new file mode 100644 index 000000000..e8af21661 --- /dev/null +++ b/apps/peertube-runner/src/register/register.ts | |||
@@ -0,0 +1,36 @@ | |||
1 | import { IPCClient } from '../shared/ipc/index.js' | ||
2 | |||
3 | export async function registerRunner (options: { | ||
4 | url: string | ||
5 | registrationToken: string | ||
6 | runnerName: string | ||
7 | runnerDescription?: string | ||
8 | }) { | ||
9 | const client = new IPCClient() | ||
10 | await client.run() | ||
11 | |||
12 | await client.askRegister(options) | ||
13 | |||
14 | client.stop() | ||
15 | } | ||
16 | |||
17 | export async function unregisterRunner (options: { | ||
18 | url: string | ||
19 | runnerName: string | ||
20 | }) { | ||
21 | const client = new IPCClient() | ||
22 | await client.run() | ||
23 | |||
24 | await client.askUnregister(options) | ||
25 | |||
26 | client.stop() | ||
27 | } | ||
28 | |||
29 | export async function listRegistered () { | ||
30 | const client = new IPCClient() | ||
31 | await client.run() | ||
32 | |||
33 | await client.askListRegistered() | ||
34 | |||
35 | client.stop() | ||
36 | } | ||
diff --git a/apps/peertube-runner/src/server/index.ts b/apps/peertube-runner/src/server/index.ts new file mode 100644 index 000000000..e56cda526 --- /dev/null +++ b/apps/peertube-runner/src/server/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './server.js' | |||
diff --git a/apps/peertube-runner/src/server/process/index.ts b/apps/peertube-runner/src/server/process/index.ts new file mode 100644 index 000000000..64a7b00fc --- /dev/null +++ b/apps/peertube-runner/src/server/process/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './shared/index.js' | ||
2 | export * from './process.js' | ||
diff --git a/apps/peertube-runner/src/server/process/process.ts b/apps/peertube-runner/src/server/process/process.ts new file mode 100644 index 000000000..e8a1d7c28 --- /dev/null +++ b/apps/peertube-runner/src/server/process/process.ts | |||
@@ -0,0 +1,34 @@ | |||
1 | import { | ||
2 | RunnerJobLiveRTMPHLSTranscodingPayload, | ||
3 | RunnerJobStudioTranscodingPayload, | ||
4 | RunnerJobVODAudioMergeTranscodingPayload, | ||
5 | RunnerJobVODHLSTranscodingPayload, | ||
6 | RunnerJobVODWebVideoTranscodingPayload | ||
7 | } from '@peertube/peertube-models' | ||
8 | import { logger } from '../../shared/index.js' | ||
9 | import { processAudioMergeTranscoding, processHLSTranscoding, ProcessOptions, processWebVideoTranscoding } from './shared/index.js' | ||
10 | import { ProcessLiveRTMPHLSTranscoding } from './shared/process-live.js' | ||
11 | import { processStudioTranscoding } from './shared/process-studio.js' | ||
12 | |||
13 | export async function processJob (options: ProcessOptions) { | ||
14 | const { server, job } = options | ||
15 | |||
16 | logger.info(`[${server.url}] Processing job of type ${job.type}: ${job.uuid}`, { payload: job.payload }) | ||
17 | |||
18 | if (job.type === 'vod-audio-merge-transcoding') { | ||
19 | await processAudioMergeTranscoding(options as ProcessOptions<RunnerJobVODAudioMergeTranscodingPayload>) | ||
20 | } else if (job.type === 'vod-web-video-transcoding') { | ||
21 | await processWebVideoTranscoding(options as ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) | ||
22 | } else if (job.type === 'vod-hls-transcoding') { | ||
23 | await processHLSTranscoding(options as ProcessOptions<RunnerJobVODHLSTranscodingPayload>) | ||
24 | } else if (job.type === 'live-rtmp-hls-transcoding') { | ||
25 | await new ProcessLiveRTMPHLSTranscoding(options as ProcessOptions<RunnerJobLiveRTMPHLSTranscodingPayload>).process() | ||
26 | } else if (job.type === 'video-studio-transcoding') { | ||
27 | await processStudioTranscoding(options as ProcessOptions<RunnerJobStudioTranscodingPayload>) | ||
28 | } else { | ||
29 | logger.error(`Unknown job ${job.type} to process`) | ||
30 | return | ||
31 | } | ||
32 | |||
33 | logger.info(`[${server.url}] Finished processing job of type ${job.type}: ${job.uuid}`) | ||
34 | } | ||
diff --git a/apps/peertube-runner/src/server/process/shared/common.ts b/apps/peertube-runner/src/server/process/shared/common.ts new file mode 100644 index 000000000..09241d93b --- /dev/null +++ b/apps/peertube-runner/src/server/process/shared/common.ts | |||
@@ -0,0 +1,106 @@ | |||
1 | import { remove } from 'fs-extra/esm' | ||
2 | import { join } from 'path' | ||
3 | import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg' | ||
4 | import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models' | ||
5 | import { buildUUID } from '@peertube/peertube-node-utils' | ||
6 | import { PeerTubeServer } from '@peertube/peertube-server-commands' | ||
7 | import { ConfigManager, downloadFile, logger } from '../../../shared/index.js' | ||
8 | import { getTranscodingLogger } from './transcoding-logger.js' | ||
9 | |||
10 | export type JobWithToken <T extends RunnerJobPayload = RunnerJobPayload> = RunnerJob<T> & { jobToken: string } | ||
11 | |||
12 | export type ProcessOptions <T extends RunnerJobPayload = RunnerJobPayload> = { | ||
13 | server: PeerTubeServer | ||
14 | job: JobWithToken<T> | ||
15 | runnerToken: string | ||
16 | } | ||
17 | |||
18 | export async function downloadInputFile (options: { | ||
19 | url: string | ||
20 | job: JobWithToken | ||
21 | runnerToken: string | ||
22 | }) { | ||
23 | const { url, job, runnerToken } = options | ||
24 | const destination = join(ConfigManager.Instance.getTranscodingDirectory(), buildUUID()) | ||
25 | |||
26 | try { | ||
27 | await downloadFile({ url, jobToken: job.jobToken, runnerToken, destination }) | ||
28 | } catch (err) { | ||
29 | remove(destination) | ||
30 | .catch(err => logger.error({ err }, `Cannot remove ${destination}`)) | ||
31 | |||
32 | throw err | ||
33 | } | ||
34 | |||
35 | return destination | ||
36 | } | ||
37 | |||
38 | export function scheduleTranscodingProgress (options: { | ||
39 | server: PeerTubeServer | ||
40 | runnerToken: string | ||
41 | job: JobWithToken | ||
42 | progressGetter: () => number | ||
43 | }) { | ||
44 | const { job, server, progressGetter, runnerToken } = options | ||
45 | |||
46 | const updateInterval = ConfigManager.Instance.isTestInstance() | ||
47 | ? 500 | ||
48 | : 60000 | ||
49 | |||
50 | const update = () => { | ||
51 | server.runnerJobs.update({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken, progress: progressGetter() }) | ||
52 | .catch(err => logger.error({ err }, 'Cannot send job progress')) | ||
53 | } | ||
54 | |||
55 | const interval = setInterval(() => { | ||
56 | update() | ||
57 | }, updateInterval) | ||
58 | |||
59 | update() | ||
60 | |||
61 | return interval | ||
62 | } | ||
63 | |||
64 | // --------------------------------------------------------------------------- | ||
65 | |||
66 | export function buildFFmpegVOD (options: { | ||
67 | onJobProgress: (progress: number) => void | ||
68 | }) { | ||
69 | const { onJobProgress } = options | ||
70 | |||
71 | return new FFmpegVOD({ | ||
72 | ...getCommonFFmpegOptions(), | ||
73 | |||
74 | updateJobProgress: arg => { | ||
75 | const progress = arg < 0 || arg > 100 | ||
76 | ? undefined | ||
77 | : arg | ||
78 | |||
79 | onJobProgress(progress) | ||
80 | } | ||
81 | }) | ||
82 | } | ||
83 | |||
84 | export function buildFFmpegLive () { | ||
85 | return new FFmpegLive(getCommonFFmpegOptions()) | ||
86 | } | ||
87 | |||
88 | export function buildFFmpegEdition () { | ||
89 | return new FFmpegEdition(getCommonFFmpegOptions()) | ||
90 | } | ||
91 | |||
92 | function getCommonFFmpegOptions () { | ||
93 | const config = ConfigManager.Instance.getConfig() | ||
94 | |||
95 | return { | ||
96 | niceness: config.ffmpeg.nice, | ||
97 | threads: config.ffmpeg.threads, | ||
98 | tmpDirectory: ConfigManager.Instance.getTranscodingDirectory(), | ||
99 | profile: 'default', | ||
100 | availableEncoders: { | ||
101 | available: getDefaultAvailableEncoders(), | ||
102 | encodersToTry: getDefaultEncodersToTry() | ||
103 | }, | ||
104 | logger: getTranscodingLogger() | ||
105 | } | ||
106 | } | ||
diff --git a/apps/peertube-runner/src/server/process/shared/index.ts b/apps/peertube-runner/src/server/process/shared/index.ts new file mode 100644 index 000000000..638bf127f --- /dev/null +++ b/apps/peertube-runner/src/server/process/shared/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './common.js' | ||
2 | export * from './process-vod.js' | ||
3 | export * from './transcoding-logger.js' | ||
diff --git a/apps/peertube-runner/src/server/process/shared/process-live.ts b/apps/peertube-runner/src/server/process/shared/process-live.ts new file mode 100644 index 000000000..0dc4e5b13 --- /dev/null +++ b/apps/peertube-runner/src/server/process/shared/process-live.ts | |||
@@ -0,0 +1,338 @@ | |||
1 | import { FSWatcher, watch } from 'chokidar' | ||
2 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
3 | import { ensureDir, remove } from 'fs-extra/esm' | ||
4 | import { basename, join } from 'path' | ||
5 | import { wait } from '@peertube/peertube-core-utils' | ||
6 | import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '@peertube/peertube-ffmpeg' | ||
7 | import { | ||
8 | LiveRTMPHLSTranscodingSuccess, | ||
9 | LiveRTMPHLSTranscodingUpdatePayload, | ||
10 | PeerTubeProblemDocument, | ||
11 | RunnerJobLiveRTMPHLSTranscodingPayload, | ||
12 | ServerErrorCode | ||
13 | } from '@peertube/peertube-models' | ||
14 | import { buildUUID } from '@peertube/peertube-node-utils' | ||
15 | import { ConfigManager } from '../../../shared/config-manager.js' | ||
16 | import { logger } from '../../../shared/index.js' | ||
17 | import { buildFFmpegLive, ProcessOptions } from './common.js' | ||
18 | |||
19 | export class ProcessLiveRTMPHLSTranscoding { | ||
20 | |||
21 | private readonly outputPath: string | ||
22 | private readonly fsWatchers: FSWatcher[] = [] | ||
23 | |||
24 | // Playlist name -> chunks | ||
25 | private readonly pendingChunksPerPlaylist = new Map<string, string[]>() | ||
26 | |||
27 | private readonly playlistsCreated = new Set<string>() | ||
28 | private allPlaylistsCreated = false | ||
29 | |||
30 | private ffmpegCommand: FfmpegCommand | ||
31 | |||
32 | private ended = false | ||
33 | private errored = false | ||
34 | |||
35 | constructor (private readonly options: ProcessOptions<RunnerJobLiveRTMPHLSTranscodingPayload>) { | ||
36 | this.outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), buildUUID()) | ||
37 | |||
38 | logger.debug(`Using ${this.outputPath} to process live rtmp hls transcoding job ${options.job.uuid}`) | ||
39 | } | ||
40 | |||
41 | process () { | ||
42 | const job = this.options.job | ||
43 | const payload = job.payload | ||
44 | |||
45 | return new Promise<void>(async (res, rej) => { | ||
46 | try { | ||
47 | await ensureDir(this.outputPath) | ||
48 | |||
49 | logger.info(`Probing ${payload.input.rtmpUrl}`) | ||
50 | const probe = await ffprobePromise(payload.input.rtmpUrl) | ||
51 | logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`) | ||
52 | |||
53 | const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe) | ||
54 | const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe) | ||
55 | const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe) | ||
56 | |||
57 | const m3u8Watcher = watch(this.outputPath + '/*.m3u8') | ||
58 | this.fsWatchers.push(m3u8Watcher) | ||
59 | |||
60 | const tsWatcher = watch(this.outputPath + '/*.ts') | ||
61 | this.fsWatchers.push(tsWatcher) | ||
62 | |||
63 | m3u8Watcher.on('change', p => { | ||
64 | logger.debug(`${p} m3u8 playlist changed`) | ||
65 | }) | ||
66 | |||
67 | m3u8Watcher.on('add', p => { | ||
68 | this.playlistsCreated.add(p) | ||
69 | |||
70 | if (this.playlistsCreated.size === this.options.job.payload.output.toTranscode.length + 1) { | ||
71 | this.allPlaylistsCreated = true | ||
72 | logger.info('All m3u8 playlists are created.') | ||
73 | } | ||
74 | }) | ||
75 | |||
76 | tsWatcher.on('add', async p => { | ||
77 | try { | ||
78 | await this.sendPendingChunks() | ||
79 | } catch (err) { | ||
80 | this.onUpdateError({ err, rej, res }) | ||
81 | } | ||
82 | |||
83 | const playlistName = this.getPlaylistIdFromTS(p) | ||
84 | |||
85 | const pendingChunks = this.pendingChunksPerPlaylist.get(playlistName) || [] | ||
86 | pendingChunks.push(p) | ||
87 | |||
88 | this.pendingChunksPerPlaylist.set(playlistName, pendingChunks) | ||
89 | }) | ||
90 | |||
91 | tsWatcher.on('unlink', p => { | ||
92 | this.sendDeletedChunkUpdate(p) | ||
93 | .catch(err => this.onUpdateError({ err, rej, res })) | ||
94 | }) | ||
95 | |||
96 | this.ffmpegCommand = await buildFFmpegLive().getLiveTranscodingCommand({ | ||
97 | inputUrl: payload.input.rtmpUrl, | ||
98 | |||
99 | outPath: this.outputPath, | ||
100 | masterPlaylistName: 'master.m3u8', | ||
101 | |||
102 | segmentListSize: payload.output.segmentListSize, | ||
103 | segmentDuration: payload.output.segmentDuration, | ||
104 | |||
105 | toTranscode: payload.output.toTranscode, | ||
106 | |||
107 | bitrate, | ||
108 | ratio, | ||
109 | |||
110 | hasAudio | ||
111 | }) | ||
112 | |||
113 | logger.info(`Running live transcoding for ${payload.input.rtmpUrl}`) | ||
114 | |||
115 | this.ffmpegCommand.on('error', (err, stdout, stderr) => { | ||
116 | this.onFFmpegError({ err, stdout, stderr }) | ||
117 | |||
118 | res() | ||
119 | }) | ||
120 | |||
121 | this.ffmpegCommand.on('end', () => { | ||
122 | this.onFFmpegEnded() | ||
123 | .catch(err => logger.error({ err }, 'Error in FFmpeg end handler')) | ||
124 | |||
125 | res() | ||
126 | }) | ||
127 | |||
128 | this.ffmpegCommand.run() | ||
129 | } catch (err) { | ||
130 | rej(err) | ||
131 | } | ||
132 | }) | ||
133 | } | ||
134 | |||
135 | // --------------------------------------------------------------------------- | ||
136 | |||
137 | private onUpdateError (options: { | ||
138 | err: Error | ||
139 | res: () => void | ||
140 | rej: (reason?: any) => void | ||
141 | }) { | ||
142 | const { err, res, rej } = options | ||
143 | |||
144 | if (this.errored) return | ||
145 | if (this.ended) return | ||
146 | |||
147 | this.errored = true | ||
148 | |||
149 | this.ffmpegCommand.kill('SIGINT') | ||
150 | |||
151 | const type = ((err as any).res?.body as PeerTubeProblemDocument)?.code | ||
152 | if (type === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) { | ||
153 | logger.info({ err }, 'Stopping transcoding as the job is not in processing state anymore') | ||
154 | |||
155 | res() | ||
156 | } else { | ||
157 | logger.error({ err }, 'Cannot send update after added/deleted chunk, stopping live transcoding') | ||
158 | |||
159 | this.sendError(err) | ||
160 | .catch(subErr => logger.error({ err: subErr }, 'Cannot send error')) | ||
161 | |||
162 | rej(err) | ||
163 | } | ||
164 | |||
165 | this.cleanup() | ||
166 | } | ||
167 | |||
168 | // --------------------------------------------------------------------------- | ||
169 | |||
170 | private onFFmpegError (options: { | ||
171 | err: any | ||
172 | stdout: string | ||
173 | stderr: string | ||
174 | }) { | ||
175 | const { err, stdout, stderr } = options | ||
176 | |||
177 | // Don't care that we killed the ffmpeg process | ||
178 | if (err?.message?.includes('Exiting normally')) return | ||
179 | if (this.errored) return | ||
180 | if (this.ended) return | ||
181 | |||
182 | this.errored = true | ||
183 | |||
184 | logger.error({ err, stdout, stderr }, 'FFmpeg transcoding error.') | ||
185 | |||
186 | this.sendError(err) | ||
187 | .catch(subErr => logger.error({ err: subErr }, 'Cannot send error')) | ||
188 | |||
189 | this.cleanup() | ||
190 | } | ||
191 | |||
192 | private async sendError (err: Error) { | ||
193 | await this.options.server.runnerJobs.error({ | ||
194 | jobToken: this.options.job.jobToken, | ||
195 | jobUUID: this.options.job.uuid, | ||
196 | runnerToken: this.options.runnerToken, | ||
197 | message: err.message | ||
198 | }) | ||
199 | } | ||
200 | |||
201 | // --------------------------------------------------------------------------- | ||
202 | |||
203 | private async onFFmpegEnded () { | ||
204 | if (this.ended) return | ||
205 | |||
206 | this.ended = true | ||
207 | logger.info('FFmpeg ended, sending success to server') | ||
208 | |||
209 | // Wait last ffmpeg chunks generation | ||
210 | await wait(1500) | ||
211 | |||
212 | this.sendSuccess() | ||
213 | .catch(err => logger.error({ err }, 'Cannot send success')) | ||
214 | |||
215 | this.cleanup() | ||
216 | } | ||
217 | |||
218 | private async sendSuccess () { | ||
219 | const successBody: LiveRTMPHLSTranscodingSuccess = {} | ||
220 | |||
221 | await this.options.server.runnerJobs.success({ | ||
222 | jobToken: this.options.job.jobToken, | ||
223 | jobUUID: this.options.job.uuid, | ||
224 | runnerToken: this.options.runnerToken, | ||
225 | payload: successBody | ||
226 | }) | ||
227 | } | ||
228 | |||
229 | // --------------------------------------------------------------------------- | ||
230 | |||
231 | private sendDeletedChunkUpdate (deletedChunk: string): Promise<any> { | ||
232 | if (this.ended) return Promise.resolve() | ||
233 | |||
234 | logger.debug(`Sending removed live chunk ${deletedChunk} update`) | ||
235 | |||
236 | const videoChunkFilename = basename(deletedChunk) | ||
237 | |||
238 | let payload: LiveRTMPHLSTranscodingUpdatePayload = { | ||
239 | type: 'remove-chunk', | ||
240 | videoChunkFilename | ||
241 | } | ||
242 | |||
243 | if (this.allPlaylistsCreated) { | ||
244 | const playlistName = this.getPlaylistName(videoChunkFilename) | ||
245 | |||
246 | payload = { | ||
247 | ...payload, | ||
248 | masterPlaylistFile: join(this.outputPath, 'master.m3u8'), | ||
249 | resolutionPlaylistFilename: playlistName, | ||
250 | resolutionPlaylistFile: join(this.outputPath, playlistName) | ||
251 | } | ||
252 | } | ||
253 | |||
254 | return this.updateWithRetry(payload) | ||
255 | } | ||
256 | |||
257 | private async sendPendingChunks (): Promise<any> { | ||
258 | if (this.ended) return Promise.resolve() | ||
259 | |||
260 | const promises: Promise<any>[] = [] | ||
261 | |||
262 | for (const playlist of this.pendingChunksPerPlaylist.keys()) { | ||
263 | for (const chunk of this.pendingChunksPerPlaylist.get(playlist)) { | ||
264 | logger.debug(`Sending added live chunk ${chunk} update`) | ||
265 | |||
266 | const videoChunkFilename = basename(chunk) | ||
267 | |||
268 | let payload: LiveRTMPHLSTranscodingUpdatePayload = { | ||
269 | type: 'add-chunk', | ||
270 | videoChunkFilename, | ||
271 | videoChunkFile: chunk | ||
272 | } | ||
273 | |||
274 | if (this.allPlaylistsCreated) { | ||
275 | const playlistName = this.getPlaylistName(videoChunkFilename) | ||
276 | |||
277 | payload = { | ||
278 | ...payload, | ||
279 | masterPlaylistFile: join(this.outputPath, 'master.m3u8'), | ||
280 | resolutionPlaylistFilename: playlistName, | ||
281 | resolutionPlaylistFile: join(this.outputPath, playlistName) | ||
282 | } | ||
283 | } | ||
284 | |||
285 | promises.push(this.updateWithRetry(payload)) | ||
286 | } | ||
287 | |||
288 | this.pendingChunksPerPlaylist.set(playlist, []) | ||
289 | } | ||
290 | |||
291 | await Promise.all(promises) | ||
292 | } | ||
293 | |||
294 | private async updateWithRetry (payload: LiveRTMPHLSTranscodingUpdatePayload, currentTry = 1): Promise<any> { | ||
295 | if (this.ended || this.errored) return | ||
296 | |||
297 | try { | ||
298 | await this.options.server.runnerJobs.update({ | ||
299 | jobToken: this.options.job.jobToken, | ||
300 | jobUUID: this.options.job.uuid, | ||
301 | runnerToken: this.options.runnerToken, | ||
302 | payload | ||
303 | }) | ||
304 | } catch (err) { | ||
305 | if (currentTry >= 3) throw err | ||
306 | if ((err.res?.body as PeerTubeProblemDocument)?.code === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) throw err | ||
307 | |||
308 | logger.warn({ err }, 'Will retry update after error') | ||
309 | await wait(250) | ||
310 | |||
311 | return this.updateWithRetry(payload, currentTry + 1) | ||
312 | } | ||
313 | } | ||
314 | |||
315 | private getPlaylistName (videoChunkFilename: string) { | ||
316 | return `${videoChunkFilename.split('-')[0]}.m3u8` | ||
317 | } | ||
318 | |||
319 | private getPlaylistIdFromTS (segmentPath: string) { | ||
320 | const playlistIdMatcher = /^([\d+])-/ | ||
321 | |||
322 | return basename(segmentPath).match(playlistIdMatcher)[1] | ||
323 | } | ||
324 | |||
325 | // --------------------------------------------------------------------------- | ||
326 | |||
327 | private cleanup () { | ||
328 | logger.debug(`Cleaning up job ${this.options.job.uuid}`) | ||
329 | |||
330 | for (const fsWatcher of this.fsWatchers) { | ||
331 | fsWatcher.close() | ||
332 | .catch(err => logger.error({ err }, 'Cannot close watcher')) | ||
333 | } | ||
334 | |||
335 | remove(this.outputPath) | ||
336 | .catch(err => logger.error({ err }, `Cannot remove ${this.outputPath}`)) | ||
337 | } | ||
338 | } | ||
diff --git a/apps/peertube-runner/src/server/process/shared/process-studio.ts b/apps/peertube-runner/src/server/process/shared/process-studio.ts new file mode 100644 index 000000000..11b7b7d9a --- /dev/null +++ b/apps/peertube-runner/src/server/process/shared/process-studio.ts | |||
@@ -0,0 +1,165 @@ | |||
1 | import { remove } from 'fs-extra/esm' | ||
2 | import { join } from 'path' | ||
3 | import { pick } from '@peertube/peertube-core-utils' | ||
4 | import { | ||
5 | RunnerJobStudioTranscodingPayload, | ||
6 | VideoStudioTask, | ||
7 | VideoStudioTaskCutPayload, | ||
8 | VideoStudioTaskIntroPayload, | ||
9 | VideoStudioTaskOutroPayload, | ||
10 | VideoStudioTaskPayload, | ||
11 | VideoStudioTaskWatermarkPayload, | ||
12 | VideoStudioTranscodingSuccess | ||
13 | } from '@peertube/peertube-models' | ||
14 | import { buildUUID } from '@peertube/peertube-node-utils' | ||
15 | import { ConfigManager } from '../../../shared/config-manager.js' | ||
16 | import { logger } from '../../../shared/index.js' | ||
17 | import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions, scheduleTranscodingProgress } from './common.js' | ||
18 | |||
19 | export async function processStudioTranscoding (options: ProcessOptions<RunnerJobStudioTranscodingPayload>) { | ||
20 | const { server, job, runnerToken } = options | ||
21 | const payload = job.payload | ||
22 | |||
23 | let inputPath: string | ||
24 | let outputPath: string | ||
25 | let tmpInputFilePath: string | ||
26 | |||
27 | let tasksProgress = 0 | ||
28 | |||
29 | const updateProgressInterval = scheduleTranscodingProgress({ | ||
30 | job, | ||
31 | server, | ||
32 | runnerToken, | ||
33 | progressGetter: () => tasksProgress | ||
34 | }) | ||
35 | |||
36 | try { | ||
37 | logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`) | ||
38 | |||
39 | inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) | ||
40 | tmpInputFilePath = inputPath | ||
41 | |||
42 | logger.info(`Input file ${payload.input.videoFileUrl} downloaded for job ${job.jobToken}. Running studio transcoding tasks.`) | ||
43 | |||
44 | for (const task of payload.tasks) { | ||
45 | const outputFilename = 'output-edition-' + buildUUID() + '.mp4' | ||
46 | outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename) | ||
47 | |||
48 | await processTask({ | ||
49 | inputPath: tmpInputFilePath, | ||
50 | outputPath, | ||
51 | task, | ||
52 | job, | ||
53 | runnerToken | ||
54 | }) | ||
55 | |||
56 | if (tmpInputFilePath) await remove(tmpInputFilePath) | ||
57 | |||
58 | // For the next iteration | ||
59 | tmpInputFilePath = outputPath | ||
60 | |||
61 | tasksProgress += Math.floor(100 / payload.tasks.length) | ||
62 | } | ||
63 | |||
64 | const successBody: VideoStudioTranscodingSuccess = { | ||
65 | videoFile: outputPath | ||
66 | } | ||
67 | |||
68 | await server.runnerJobs.success({ | ||
69 | jobToken: job.jobToken, | ||
70 | jobUUID: job.uuid, | ||
71 | runnerToken, | ||
72 | payload: successBody | ||
73 | }) | ||
74 | } finally { | ||
75 | if (tmpInputFilePath) await remove(tmpInputFilePath) | ||
76 | if (outputPath) await remove(outputPath) | ||
77 | if (updateProgressInterval) clearInterval(updateProgressInterval) | ||
78 | } | ||
79 | } | ||
80 | |||
81 | // --------------------------------------------------------------------------- | ||
82 | // Private | ||
83 | // --------------------------------------------------------------------------- | ||
84 | |||
85 | type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = { | ||
86 | inputPath: string | ||
87 | outputPath: string | ||
88 | task: T | ||
89 | runnerToken: string | ||
90 | job: JobWithToken | ||
91 | } | ||
92 | |||
93 | const taskProcessors: { [id in VideoStudioTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = { | ||
94 | 'add-intro': processAddIntroOutro, | ||
95 | 'add-outro': processAddIntroOutro, | ||
96 | 'cut': processCut, | ||
97 | 'add-watermark': processAddWatermark | ||
98 | } | ||
99 | |||
100 | async function processTask (options: TaskProcessorOptions) { | ||
101 | const { task } = options | ||
102 | |||
103 | const processor = taskProcessors[options.task.name] | ||
104 | if (!process) throw new Error('Unknown task ' + task.name) | ||
105 | |||
106 | return processor(options) | ||
107 | } | ||
108 | |||
109 | async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) { | ||
110 | const { inputPath, task, runnerToken, job } = options | ||
111 | |||
112 | logger.debug('Adding intro/outro to ' + inputPath) | ||
113 | |||
114 | const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job }) | ||
115 | |||
116 | try { | ||
117 | await buildFFmpegEdition().addIntroOutro({ | ||
118 | ...pick(options, [ 'inputPath', 'outputPath' ]), | ||
119 | |||
120 | introOutroPath, | ||
121 | type: task.name === 'add-intro' | ||
122 | ? 'intro' | ||
123 | : 'outro' | ||
124 | }) | ||
125 | } finally { | ||
126 | await remove(introOutroPath) | ||
127 | } | ||
128 | } | ||
129 | |||
130 | function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) { | ||
131 | const { inputPath, task } = options | ||
132 | |||
133 | logger.debug(`Cutting ${inputPath}`) | ||
134 | |||
135 | return buildFFmpegEdition().cutVideo({ | ||
136 | ...pick(options, [ 'inputPath', 'outputPath' ]), | ||
137 | |||
138 | start: task.options.start, | ||
139 | end: task.options.end | ||
140 | }) | ||
141 | } | ||
142 | |||
143 | async function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) { | ||
144 | const { inputPath, task, runnerToken, job } = options | ||
145 | |||
146 | logger.debug('Adding watermark to ' + inputPath) | ||
147 | |||
148 | const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job }) | ||
149 | |||
150 | try { | ||
151 | await buildFFmpegEdition().addWatermark({ | ||
152 | ...pick(options, [ 'inputPath', 'outputPath' ]), | ||
153 | |||
154 | watermarkPath, | ||
155 | |||
156 | videoFilters: { | ||
157 | watermarkSizeRatio: task.options.watermarkSizeRatio, | ||
158 | horitonzalMarginRatio: task.options.horitonzalMarginRatio, | ||
159 | verticalMarginRatio: task.options.verticalMarginRatio | ||
160 | } | ||
161 | }) | ||
162 | } finally { | ||
163 | await remove(watermarkPath) | ||
164 | } | ||
165 | } | ||
diff --git a/apps/peertube-runner/src/server/process/shared/process-vod.ts b/apps/peertube-runner/src/server/process/shared/process-vod.ts new file mode 100644 index 000000000..fe1715ca9 --- /dev/null +++ b/apps/peertube-runner/src/server/process/shared/process-vod.ts | |||
@@ -0,0 +1,201 @@ | |||
1 | import { remove } from 'fs-extra/esm' | ||
2 | import { join } from 'path' | ||
3 | import { | ||
4 | RunnerJobVODAudioMergeTranscodingPayload, | ||
5 | RunnerJobVODHLSTranscodingPayload, | ||
6 | RunnerJobVODWebVideoTranscodingPayload, | ||
7 | VODAudioMergeTranscodingSuccess, | ||
8 | VODHLSTranscodingSuccess, | ||
9 | VODWebVideoTranscodingSuccess | ||
10 | } from '@peertube/peertube-models' | ||
11 | import { buildUUID } from '@peertube/peertube-node-utils' | ||
12 | import { ConfigManager } from '../../../shared/config-manager.js' | ||
13 | import { logger } from '../../../shared/index.js' | ||
14 | import { buildFFmpegVOD, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common.js' | ||
15 | |||
16 | export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) { | ||
17 | const { server, job, runnerToken } = options | ||
18 | |||
19 | const payload = job.payload | ||
20 | |||
21 | let ffmpegProgress: number | ||
22 | let inputPath: string | ||
23 | |||
24 | const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`) | ||
25 | |||
26 | const updateProgressInterval = scheduleTranscodingProgress({ | ||
27 | job, | ||
28 | server, | ||
29 | runnerToken, | ||
30 | progressGetter: () => ffmpegProgress | ||
31 | }) | ||
32 | |||
33 | try { | ||
34 | logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`) | ||
35 | |||
36 | inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) | ||
37 | |||
38 | logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`) | ||
39 | |||
40 | const ffmpegVod = buildFFmpegVOD({ | ||
41 | onJobProgress: progress => { ffmpegProgress = progress } | ||
42 | }) | ||
43 | |||
44 | await ffmpegVod.transcode({ | ||
45 | type: 'video', | ||
46 | |||
47 | inputPath, | ||
48 | |||
49 | outputPath, | ||
50 | |||
51 | inputFileMutexReleaser: () => {}, | ||
52 | |||
53 | resolution: payload.output.resolution, | ||
54 | fps: payload.output.fps | ||
55 | }) | ||
56 | |||
57 | const successBody: VODWebVideoTranscodingSuccess = { | ||
58 | videoFile: outputPath | ||
59 | } | ||
60 | |||
61 | await server.runnerJobs.success({ | ||
62 | jobToken: job.jobToken, | ||
63 | jobUUID: job.uuid, | ||
64 | runnerToken, | ||
65 | payload: successBody | ||
66 | }) | ||
67 | } finally { | ||
68 | if (inputPath) await remove(inputPath) | ||
69 | if (outputPath) await remove(outputPath) | ||
70 | if (updateProgressInterval) clearInterval(updateProgressInterval) | ||
71 | } | ||
72 | } | ||
73 | |||
74 | export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVODHLSTranscodingPayload>) { | ||
75 | const { server, job, runnerToken } = options | ||
76 | const payload = job.payload | ||
77 | |||
78 | let ffmpegProgress: number | ||
79 | let inputPath: string | ||
80 | |||
81 | const uuid = buildUUID() | ||
82 | const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`) | ||
83 | const videoFilename = `${uuid}-${payload.output.resolution}-fragmented.mp4` | ||
84 | const videoPath = join(join(ConfigManager.Instance.getTranscodingDirectory(), videoFilename)) | ||
85 | |||
86 | const updateProgressInterval = scheduleTranscodingProgress({ | ||
87 | job, | ||
88 | server, | ||
89 | runnerToken, | ||
90 | progressGetter: () => ffmpegProgress | ||
91 | }) | ||
92 | |||
93 | try { | ||
94 | logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`) | ||
95 | |||
96 | inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) | ||
97 | |||
98 | logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`) | ||
99 | |||
100 | const ffmpegVod = buildFFmpegVOD({ | ||
101 | onJobProgress: progress => { ffmpegProgress = progress } | ||
102 | }) | ||
103 | |||
104 | await ffmpegVod.transcode({ | ||
105 | type: 'hls', | ||
106 | copyCodecs: false, | ||
107 | inputPath, | ||
108 | hlsPlaylist: { videoFilename }, | ||
109 | outputPath, | ||
110 | |||
111 | inputFileMutexReleaser: () => {}, | ||
112 | |||
113 | resolution: payload.output.resolution, | ||
114 | fps: payload.output.fps | ||
115 | }) | ||
116 | |||
117 | const successBody: VODHLSTranscodingSuccess = { | ||
118 | resolutionPlaylistFile: outputPath, | ||
119 | videoFile: videoPath | ||
120 | } | ||
121 | |||
122 | await server.runnerJobs.success({ | ||
123 | jobToken: job.jobToken, | ||
124 | jobUUID: job.uuid, | ||
125 | runnerToken, | ||
126 | payload: successBody | ||
127 | }) | ||
128 | } finally { | ||
129 | if (inputPath) await remove(inputPath) | ||
130 | if (outputPath) await remove(outputPath) | ||
131 | if (videoPath) await remove(videoPath) | ||
132 | if (updateProgressInterval) clearInterval(updateProgressInterval) | ||
133 | } | ||
134 | } | ||
135 | |||
136 | export async function processAudioMergeTranscoding (options: ProcessOptions<RunnerJobVODAudioMergeTranscodingPayload>) { | ||
137 | const { server, job, runnerToken } = options | ||
138 | const payload = job.payload | ||
139 | |||
140 | let ffmpegProgress: number | ||
141 | let audioPath: string | ||
142 | let inputPath: string | ||
143 | |||
144 | const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`) | ||
145 | |||
146 | const updateProgressInterval = scheduleTranscodingProgress({ | ||
147 | job, | ||
148 | server, | ||
149 | runnerToken, | ||
150 | progressGetter: () => ffmpegProgress | ||
151 | }) | ||
152 | |||
153 | try { | ||
154 | logger.info( | ||
155 | `Downloading input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` + | ||
156 | `for audio merge transcoding job ${job.jobToken}` | ||
157 | ) | ||
158 | |||
159 | audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job }) | ||
160 | inputPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job }) | ||
161 | |||
162 | logger.info( | ||
163 | `Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` + | ||
164 | `for job ${job.jobToken}. Running audio merge transcoding.` | ||
165 | ) | ||
166 | |||
167 | const ffmpegVod = buildFFmpegVOD({ | ||
168 | onJobProgress: progress => { ffmpegProgress = progress } | ||
169 | }) | ||
170 | |||
171 | await ffmpegVod.transcode({ | ||
172 | type: 'merge-audio', | ||
173 | |||
174 | audioPath, | ||
175 | inputPath, | ||
176 | |||
177 | outputPath, | ||
178 | |||
179 | inputFileMutexReleaser: () => {}, | ||
180 | |||
181 | resolution: payload.output.resolution, | ||
182 | fps: payload.output.fps | ||
183 | }) | ||
184 | |||
185 | const successBody: VODAudioMergeTranscodingSuccess = { | ||
186 | videoFile: outputPath | ||
187 | } | ||
188 | |||
189 | await server.runnerJobs.success({ | ||
190 | jobToken: job.jobToken, | ||
191 | jobUUID: job.uuid, | ||
192 | runnerToken, | ||
193 | payload: successBody | ||
194 | }) | ||
195 | } finally { | ||
196 | if (audioPath) await remove(audioPath) | ||
197 | if (inputPath) await remove(inputPath) | ||
198 | if (outputPath) await remove(outputPath) | ||
199 | if (updateProgressInterval) clearInterval(updateProgressInterval) | ||
200 | } | ||
201 | } | ||
diff --git a/apps/peertube-runner/src/server/process/shared/transcoding-logger.ts b/apps/peertube-runner/src/server/process/shared/transcoding-logger.ts new file mode 100644 index 000000000..041dd62eb --- /dev/null +++ b/apps/peertube-runner/src/server/process/shared/transcoding-logger.ts | |||
@@ -0,0 +1,10 @@ | |||
1 | import { logger } from '../../../shared/index.js' | ||
2 | |||
3 | export function getTranscodingLogger () { | ||
4 | return { | ||
5 | info: logger.info.bind(logger), | ||
6 | debug: logger.debug.bind(logger), | ||
7 | warn: logger.warn.bind(logger), | ||
8 | error: logger.error.bind(logger) | ||
9 | } | ||
10 | } | ||
diff --git a/apps/peertube-runner/src/server/server.ts b/apps/peertube-runner/src/server/server.ts new file mode 100644 index 000000000..825e3f297 --- /dev/null +++ b/apps/peertube-runner/src/server/server.ts | |||
@@ -0,0 +1,307 @@ | |||
1 | import { ensureDir, remove } from 'fs-extra/esm' | ||
2 | import { readdir } from 'fs/promises' | ||
3 | import { join } from 'path' | ||
4 | import { io, Socket } from 'socket.io-client' | ||
5 | import { pick, shuffle, wait } from '@peertube/peertube-core-utils' | ||
6 | import { PeerTubeProblemDocument, ServerErrorCode } from '@peertube/peertube-models' | ||
7 | import { PeerTubeServer as PeerTubeServerCommand } from '@peertube/peertube-server-commands' | ||
8 | import { ConfigManager } from '../shared/index.js' | ||
9 | import { IPCServer } from '../shared/ipc/index.js' | ||
10 | import { logger } from '../shared/logger.js' | ||
11 | import { JobWithToken, processJob } from './process/index.js' | ||
12 | import { isJobSupported } from './shared/index.js' | ||
13 | |||
14 | type PeerTubeServer = PeerTubeServerCommand & { | ||
15 | runnerToken: string | ||
16 | runnerName: string | ||
17 | runnerDescription?: string | ||
18 | } | ||
19 | |||
20 | export class RunnerServer { | ||
21 | private static instance: RunnerServer | ||
22 | |||
23 | private servers: PeerTubeServer[] = [] | ||
24 | private processingJobs: { job: JobWithToken, server: PeerTubeServer }[] = [] | ||
25 | |||
26 | private checkingAvailableJobs = false | ||
27 | |||
28 | private cleaningUp = false | ||
29 | |||
30 | private readonly sockets = new Map<PeerTubeServer, Socket>() | ||
31 | |||
32 | private constructor () {} | ||
33 | |||
34 | async run () { | ||
35 | logger.info('Running PeerTube runner in server mode') | ||
36 | |||
37 | await ConfigManager.Instance.load() | ||
38 | |||
39 | for (const registered of ConfigManager.Instance.getConfig().registeredInstances) { | ||
40 | const serverCommand = new PeerTubeServerCommand({ url: registered.url }) | ||
41 | |||
42 | this.loadServer(Object.assign(serverCommand, registered)) | ||
43 | |||
44 | logger.info(`Loading registered instance ${registered.url}`) | ||
45 | } | ||
46 | |||
47 | // Run IPC | ||
48 | const ipcServer = new IPCServer() | ||
49 | try { | ||
50 | await ipcServer.run(this) | ||
51 | } catch (err) { | ||
52 | logger.error('Cannot start local socket for IPC communication', err) | ||
53 | process.exit(-1) | ||
54 | } | ||
55 | |||
56 | // Cleanup on exit | ||
57 | for (const code of [ 'SIGINT', 'SIGUSR1', 'SIGUSR2', 'uncaughtException' ]) { | ||
58 | process.on(code, async (err, origin) => { | ||
59 | if (code === 'uncaughtException') { | ||
60 | logger.error({ err, origin }, 'uncaughtException') | ||
61 | } | ||
62 | |||
63 | await this.onExit() | ||
64 | }) | ||
65 | } | ||
66 | |||
67 | // Process jobs | ||
68 | await ensureDir(ConfigManager.Instance.getTranscodingDirectory()) | ||
69 | await this.cleanupTMP() | ||
70 | |||
71 | logger.info(`Using ${ConfigManager.Instance.getTranscodingDirectory()} for transcoding directory`) | ||
72 | |||
73 | await this.checkAvailableJobs() | ||
74 | } | ||
75 | |||
76 | // --------------------------------------------------------------------------- | ||
77 | |||
78 | async registerRunner (options: { | ||
79 | url: string | ||
80 | registrationToken: string | ||
81 | runnerName: string | ||
82 | runnerDescription?: string | ||
83 | }) { | ||
84 | const { url, registrationToken, runnerName, runnerDescription } = options | ||
85 | |||
86 | logger.info(`Registering runner ${runnerName} on ${url}...`) | ||
87 | |||
88 | const serverCommand = new PeerTubeServerCommand({ url }) | ||
89 | const { runnerToken } = await serverCommand.runners.register({ name: runnerName, description: runnerDescription, registrationToken }) | ||
90 | |||
91 | const server: PeerTubeServer = Object.assign(serverCommand, { | ||
92 | runnerToken, | ||
93 | runnerName, | ||
94 | runnerDescription | ||
95 | }) | ||
96 | |||
97 | this.loadServer(server) | ||
98 | await this.saveRegisteredInstancesInConf() | ||
99 | |||
100 | logger.info(`Registered runner ${runnerName} on ${url}`) | ||
101 | |||
102 | await this.checkAvailableJobs() | ||
103 | } | ||
104 | |||
105 | private loadServer (server: PeerTubeServer) { | ||
106 | this.servers.push(server) | ||
107 | |||
108 | const url = server.url + '/runners' | ||
109 | const socket = io(url, { | ||
110 | auth: { | ||
111 | runnerToken: server.runnerToken | ||
112 | }, | ||
113 | transports: [ 'websocket' ] | ||
114 | }) | ||
115 | |||
116 | socket.on('connect_error', err => logger.warn({ err }, `Cannot connect to ${url} socket`)) | ||
117 | socket.on('connect', () => logger.info(`Connected to ${url} socket`)) | ||
118 | socket.on('available-jobs', () => this.checkAvailableJobs()) | ||
119 | |||
120 | this.sockets.set(server, socket) | ||
121 | } | ||
122 | |||
123 | async unregisterRunner (options: { | ||
124 | url: string | ||
125 | runnerName: string | ||
126 | }) { | ||
127 | const { url, runnerName } = options | ||
128 | |||
129 | const server = this.servers.find(s => s.url === url && s.runnerName === runnerName) | ||
130 | if (!server) { | ||
131 | logger.error(`Unknown server ${url} - ${runnerName} to unregister`) | ||
132 | return | ||
133 | } | ||
134 | |||
135 | logger.info(`Unregistering runner ${runnerName} on ${url}...`) | ||
136 | |||
137 | try { | ||
138 | await server.runners.unregister({ runnerToken: server.runnerToken }) | ||
139 | } catch (err) { | ||
140 | logger.error({ err }, `Cannot unregister runner ${runnerName} on ${url}`) | ||
141 | } | ||
142 | |||
143 | this.unloadServer(server) | ||
144 | await this.saveRegisteredInstancesInConf() | ||
145 | |||
146 | logger.info(`Unregistered runner ${runnerName} on ${url}`) | ||
147 | } | ||
148 | |||
149 | private unloadServer (server: PeerTubeServer) { | ||
150 | this.servers = this.servers.filter(s => s !== server) | ||
151 | |||
152 | const socket = this.sockets.get(server) | ||
153 | socket.disconnect() | ||
154 | |||
155 | this.sockets.delete(server) | ||
156 | } | ||
157 | |||
158 | listRegistered () { | ||
159 | return { | ||
160 | servers: this.servers.map(s => { | ||
161 | return { | ||
162 | url: s.url, | ||
163 | runnerName: s.runnerName, | ||
164 | runnerDescription: s.runnerDescription | ||
165 | } | ||
166 | }) | ||
167 | } | ||
168 | } | ||
169 | |||
170 | // --------------------------------------------------------------------------- | ||
171 | |||
172 | private async checkAvailableJobs () { | ||
173 | if (this.checkingAvailableJobs) return | ||
174 | |||
175 | this.checkingAvailableJobs = true | ||
176 | |||
177 | let hadAvailableJob = false | ||
178 | |||
179 | for (const server of shuffle([ ...this.servers ])) { | ||
180 | try { | ||
181 | logger.info('Checking available jobs on ' + server.url) | ||
182 | |||
183 | const job = await this.requestJob(server) | ||
184 | if (!job) continue | ||
185 | |||
186 | hadAvailableJob = true | ||
187 | |||
188 | await this.tryToExecuteJobAsync(server, job) | ||
189 | } catch (err) { | ||
190 | const code = (err.res?.body as PeerTubeProblemDocument)?.code | ||
191 | |||
192 | if (code === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) { | ||
193 | logger.debug({ err }, 'Runner job is not in processing state anymore, retry later') | ||
194 | return | ||
195 | } | ||
196 | |||
197 | if (code === ServerErrorCode.UNKNOWN_RUNNER_TOKEN) { | ||
198 | logger.error({ err }, `Unregistering ${server.url} as the runner token ${server.runnerToken} is invalid`) | ||
199 | |||
200 | await this.unregisterRunner({ url: server.url, runnerName: server.runnerName }) | ||
201 | return | ||
202 | } | ||
203 | |||
204 | logger.error({ err }, `Cannot request/accept job on ${server.url} for runner ${server.runnerName}`) | ||
205 | } | ||
206 | } | ||
207 | |||
208 | this.checkingAvailableJobs = false | ||
209 | |||
210 | if (hadAvailableJob && this.canProcessMoreJobs()) { | ||
211 | await wait(2500) | ||
212 | |||
213 | this.checkAvailableJobs() | ||
214 | .catch(err => logger.error({ err }, 'Cannot check more available jobs')) | ||
215 | } | ||
216 | } | ||
217 | |||
218 | private async requestJob (server: PeerTubeServer) { | ||
219 | logger.debug(`Requesting jobs on ${server.url} for runner ${server.runnerName}`) | ||
220 | |||
221 | const { availableJobs } = await server.runnerJobs.request({ runnerToken: server.runnerToken }) | ||
222 | |||
223 | const filtered = availableJobs.filter(j => isJobSupported(j)) | ||
224 | |||
225 | if (filtered.length === 0) { | ||
226 | logger.debug(`No job available on ${server.url} for runner ${server.runnerName}`) | ||
227 | return undefined | ||
228 | } | ||
229 | |||
230 | return filtered[0] | ||
231 | } | ||
232 | |||
233 | private async tryToExecuteJobAsync (server: PeerTubeServer, jobToAccept: { uuid: string }) { | ||
234 | if (!this.canProcessMoreJobs()) return | ||
235 | |||
236 | const { job } = await server.runnerJobs.accept({ runnerToken: server.runnerToken, jobUUID: jobToAccept.uuid }) | ||
237 | |||
238 | const processingJob = { job, server } | ||
239 | this.processingJobs.push(processingJob) | ||
240 | |||
241 | processJob({ server, job, runnerToken: server.runnerToken }) | ||
242 | .catch(err => { | ||
243 | logger.error({ err }, 'Cannot process job') | ||
244 | |||
245 | server.runnerJobs.error({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken: server.runnerToken, message: err.message }) | ||
246 | .catch(err2 => logger.error({ err: err2 }, 'Cannot abort job after error')) | ||
247 | }) | ||
248 | .finally(() => { | ||
249 | this.processingJobs = this.processingJobs.filter(p => p !== processingJob) | ||
250 | |||
251 | return this.checkAvailableJobs() | ||
252 | }) | ||
253 | } | ||
254 | |||
255 | // --------------------------------------------------------------------------- | ||
256 | |||
257 | private saveRegisteredInstancesInConf () { | ||
258 | const data = this.servers.map(s => { | ||
259 | return pick(s, [ 'url', 'runnerToken', 'runnerName', 'runnerDescription' ]) | ||
260 | }) | ||
261 | |||
262 | return ConfigManager.Instance.setRegisteredInstances(data) | ||
263 | } | ||
264 | |||
265 | private canProcessMoreJobs () { | ||
266 | return this.processingJobs.length < ConfigManager.Instance.getConfig().jobs.concurrency | ||
267 | } | ||
268 | |||
269 | // --------------------------------------------------------------------------- | ||
270 | |||
271 | private async cleanupTMP () { | ||
272 | const files = await readdir(ConfigManager.Instance.getTranscodingDirectory()) | ||
273 | |||
274 | for (const file of files) { | ||
275 | await remove(join(ConfigManager.Instance.getTranscodingDirectory(), file)) | ||
276 | } | ||
277 | } | ||
278 | |||
279 | private async onExit () { | ||
280 | if (this.cleaningUp) return | ||
281 | this.cleaningUp = true | ||
282 | |||
283 | logger.info('Cleaning up after program exit') | ||
284 | |||
285 | try { | ||
286 | for (const { server, job } of this.processingJobs) { | ||
287 | await server.runnerJobs.abort({ | ||
288 | jobToken: job.jobToken, | ||
289 | jobUUID: job.uuid, | ||
290 | reason: 'Runner stopped', | ||
291 | runnerToken: server.runnerToken | ||
292 | }) | ||
293 | } | ||
294 | |||
295 | await this.cleanupTMP() | ||
296 | } catch (err) { | ||
297 | logger.error(err) | ||
298 | process.exit(-1) | ||
299 | } | ||
300 | |||
301 | process.exit() | ||
302 | } | ||
303 | |||
304 | static get Instance () { | ||
305 | return this.instance || (this.instance = new this()) | ||
306 | } | ||
307 | } | ||
diff --git a/apps/peertube-runner/src/server/shared/index.ts b/apps/peertube-runner/src/server/shared/index.ts new file mode 100644 index 000000000..34d51196b --- /dev/null +++ b/apps/peertube-runner/src/server/shared/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './supported-job.js' | |||
diff --git a/apps/peertube-runner/src/server/shared/supported-job.ts b/apps/peertube-runner/src/server/shared/supported-job.ts new file mode 100644 index 000000000..d905b5de2 --- /dev/null +++ b/apps/peertube-runner/src/server/shared/supported-job.ts | |||
@@ -0,0 +1,43 @@ | |||
1 | import { | ||
2 | RunnerJobLiveRTMPHLSTranscodingPayload, | ||
3 | RunnerJobPayload, | ||
4 | RunnerJobType, | ||
5 | RunnerJobStudioTranscodingPayload, | ||
6 | RunnerJobVODAudioMergeTranscodingPayload, | ||
7 | RunnerJobVODHLSTranscodingPayload, | ||
8 | RunnerJobVODWebVideoTranscodingPayload, | ||
9 | VideoStudioTaskPayload | ||
10 | } from '@peertube/peertube-models' | ||
11 | |||
12 | const supportedMatrix = { | ||
13 | 'vod-web-video-transcoding': (_payload: RunnerJobVODWebVideoTranscodingPayload) => { | ||
14 | return true | ||
15 | }, | ||
16 | 'vod-hls-transcoding': (_payload: RunnerJobVODHLSTranscodingPayload) => { | ||
17 | return true | ||
18 | }, | ||
19 | 'vod-audio-merge-transcoding': (_payload: RunnerJobVODAudioMergeTranscodingPayload) => { | ||
20 | return true | ||
21 | }, | ||
22 | 'live-rtmp-hls-transcoding': (_payload: RunnerJobLiveRTMPHLSTranscodingPayload) => { | ||
23 | return true | ||
24 | }, | ||
25 | 'video-studio-transcoding': (payload: RunnerJobStudioTranscodingPayload) => { | ||
26 | const tasks = payload?.tasks | ||
27 | const supported = new Set<VideoStudioTaskPayload['name']>([ 'add-intro', 'add-outro', 'add-watermark', 'cut' ]) | ||
28 | |||
29 | if (!Array.isArray(tasks)) return false | ||
30 | |||
31 | return tasks.every(t => t && supported.has(t.name)) | ||
32 | } | ||
33 | } | ||
34 | |||
35 | export function isJobSupported (job: { | ||
36 | type: RunnerJobType | ||
37 | payload: RunnerJobPayload | ||
38 | }) { | ||
39 | const fn = supportedMatrix[job.type] | ||
40 | if (!fn) return false | ||
41 | |||
42 | return fn(job.payload as any) | ||
43 | } | ||
diff --git a/apps/peertube-runner/src/shared/config-manager.ts b/apps/peertube-runner/src/shared/config-manager.ts new file mode 100644 index 000000000..84a326a16 --- /dev/null +++ b/apps/peertube-runner/src/shared/config-manager.ts | |||
@@ -0,0 +1,140 @@ | |||
1 | import { parse, stringify } from '@iarna/toml' | ||
2 | import envPaths from 'env-paths' | ||
3 | import { ensureDir, pathExists, remove } from 'fs-extra/esm' | ||
4 | import { readFile, writeFile } from 'fs/promises' | ||
5 | import merge from 'lodash-es/merge.js' | ||
6 | import { dirname, join } from 'path' | ||
7 | import { logger } from '../shared/index.js' | ||
8 | |||
9 | const paths = envPaths('peertube-runner') | ||
10 | |||
11 | type Config = { | ||
12 | jobs: { | ||
13 | concurrency: number | ||
14 | } | ||
15 | |||
16 | ffmpeg: { | ||
17 | threads: number | ||
18 | nice: number | ||
19 | } | ||
20 | |||
21 | registeredInstances: { | ||
22 | url: string | ||
23 | runnerToken: string | ||
24 | runnerName: string | ||
25 | runnerDescription?: string | ||
26 | }[] | ||
27 | } | ||
28 | |||
29 | export class ConfigManager { | ||
30 | private static instance: ConfigManager | ||
31 | |||
32 | private config: Config = { | ||
33 | jobs: { | ||
34 | concurrency: 2 | ||
35 | }, | ||
36 | ffmpeg: { | ||
37 | threads: 2, | ||
38 | nice: 20 | ||
39 | }, | ||
40 | registeredInstances: [] | ||
41 | } | ||
42 | |||
43 | private id: string | ||
44 | private configFilePath: string | ||
45 | |||
46 | private constructor () {} | ||
47 | |||
48 | init (id: string) { | ||
49 | this.id = id | ||
50 | this.configFilePath = join(this.getConfigDir(), 'config.toml') | ||
51 | } | ||
52 | |||
53 | async load () { | ||
54 | logger.info(`Using ${this.configFilePath} as configuration file`) | ||
55 | |||
56 | if (this.isTestInstance()) { | ||
57 | logger.info('Removing configuration file as we are using the "test" id') | ||
58 | await remove(this.configFilePath) | ||
59 | } | ||
60 | |||
61 | await ensureDir(dirname(this.configFilePath)) | ||
62 | |||
63 | if (!await pathExists(this.configFilePath)) { | ||
64 | await this.save() | ||
65 | } | ||
66 | |||
67 | const file = await readFile(this.configFilePath, 'utf-8') | ||
68 | |||
69 | this.config = merge(this.config, parse(file)) | ||
70 | } | ||
71 | |||
72 | save () { | ||
73 | return writeFile(this.configFilePath, stringify(this.config)) | ||
74 | } | ||
75 | |||
76 | // --------------------------------------------------------------------------- | ||
77 | |||
78 | async setRegisteredInstances (registeredInstances: { | ||
79 | url: string | ||
80 | runnerToken: string | ||
81 | runnerName: string | ||
82 | runnerDescription?: string | ||
83 | }[]) { | ||
84 | this.config.registeredInstances = registeredInstances | ||
85 | |||
86 | await this.save() | ||
87 | } | ||
88 | |||
89 | // --------------------------------------------------------------------------- | ||
90 | |||
91 | getConfig () { | ||
92 | return this.deepFreeze(this.config) | ||
93 | } | ||
94 | |||
95 | // --------------------------------------------------------------------------- | ||
96 | |||
97 | getTranscodingDirectory () { | ||
98 | return join(paths.cache, this.id, 'transcoding') | ||
99 | } | ||
100 | |||
101 | getSocketDirectory () { | ||
102 | return join(paths.data, this.id) | ||
103 | } | ||
104 | |||
105 | getSocketPath () { | ||
106 | return join(this.getSocketDirectory(), 'peertube-runner.sock') | ||
107 | } | ||
108 | |||
109 | getConfigDir () { | ||
110 | return join(paths.config, this.id) | ||
111 | } | ||
112 | |||
113 | // --------------------------------------------------------------------------- | ||
114 | |||
115 | isTestInstance () { | ||
116 | return typeof this.id === 'string' && this.id.match(/^test-\d$/) | ||
117 | } | ||
118 | |||
119 | // --------------------------------------------------------------------------- | ||
120 | |||
121 | // Thanks: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze | ||
122 | private deepFreeze <T extends object> (object: T) { | ||
123 | const propNames = Reflect.ownKeys(object) | ||
124 | |||
125 | // Freeze properties before freezing self | ||
126 | for (const name of propNames) { | ||
127 | const value = object[name] | ||
128 | |||
129 | if ((value && typeof value === 'object') || typeof value === 'function') { | ||
130 | this.deepFreeze(value) | ||
131 | } | ||
132 | } | ||
133 | |||
134 | return Object.freeze({ ...object }) | ||
135 | } | ||
136 | |||
137 | static get Instance () { | ||
138 | return this.instance || (this.instance = new this()) | ||
139 | } | ||
140 | } | ||
diff --git a/apps/peertube-runner/src/shared/http.ts b/apps/peertube-runner/src/shared/http.ts new file mode 100644 index 000000000..42886279c --- /dev/null +++ b/apps/peertube-runner/src/shared/http.ts | |||
@@ -0,0 +1,67 @@ | |||
1 | import { createWriteStream } from 'fs' | ||
2 | import { remove } from 'fs-extra/esm' | ||
3 | import { request as requestHTTP } from 'http' | ||
4 | import { request as requestHTTPS, RequestOptions } from 'https' | ||
5 | import { logger } from './logger.js' | ||
6 | |||
7 | export function downloadFile (options: { | ||
8 | url: string | ||
9 | destination: string | ||
10 | runnerToken: string | ||
11 | jobToken: string | ||
12 | }) { | ||
13 | const { url, destination, runnerToken, jobToken } = options | ||
14 | |||
15 | logger.debug(`Downloading file ${url}`) | ||
16 | |||
17 | return new Promise<void>((res, rej) => { | ||
18 | const parsed = new URL(url) | ||
19 | |||
20 | const body = JSON.stringify({ | ||
21 | runnerToken, | ||
22 | jobToken | ||
23 | }) | ||
24 | |||
25 | const getOptions: RequestOptions = { | ||
26 | method: 'POST', | ||
27 | hostname: parsed.hostname, | ||
28 | port: parsed.port, | ||
29 | path: parsed.pathname, | ||
30 | headers: { | ||
31 | 'Content-Type': 'application/json', | ||
32 | 'Content-Length': Buffer.byteLength(body, 'utf-8') | ||
33 | } | ||
34 | } | ||
35 | |||
36 | const request = getRequest(url)(getOptions, response => { | ||
37 | const code = response.statusCode ?? 0 | ||
38 | |||
39 | if (code >= 400) { | ||
40 | return rej(new Error(response.statusMessage)) | ||
41 | } | ||
42 | |||
43 | const file = createWriteStream(destination) | ||
44 | file.on('finish', () => res()) | ||
45 | |||
46 | response.pipe(file) | ||
47 | }) | ||
48 | |||
49 | request.on('error', err => { | ||
50 | remove(destination) | ||
51 | .catch(err => logger.error(err)) | ||
52 | |||
53 | return rej(err) | ||
54 | }) | ||
55 | |||
56 | request.write(body) | ||
57 | request.end() | ||
58 | }) | ||
59 | } | ||
60 | |||
61 | // --------------------------------------------------------------------------- | ||
62 | |||
63 | function getRequest (url: string) { | ||
64 | if (url.startsWith('https://')) return requestHTTPS | ||
65 | |||
66 | return requestHTTP | ||
67 | } | ||
diff --git a/apps/peertube-runner/src/shared/index.ts b/apps/peertube-runner/src/shared/index.ts new file mode 100644 index 000000000..951eef55b --- /dev/null +++ b/apps/peertube-runner/src/shared/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './config-manager.js' | ||
2 | export * from './http.js' | ||
3 | export * from './logger.js' | ||
diff --git a/apps/peertube-runner/src/shared/ipc/index.ts b/apps/peertube-runner/src/shared/ipc/index.ts new file mode 100644 index 000000000..337d4de16 --- /dev/null +++ b/apps/peertube-runner/src/shared/ipc/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './ipc-client.js' | ||
2 | export * from './ipc-server.js' | ||
diff --git a/apps/peertube-runner/src/shared/ipc/ipc-client.ts b/apps/peertube-runner/src/shared/ipc/ipc-client.ts new file mode 100644 index 000000000..aa5740dd1 --- /dev/null +++ b/apps/peertube-runner/src/shared/ipc/ipc-client.ts | |||
@@ -0,0 +1,88 @@ | |||
1 | import CliTable3 from 'cli-table3' | ||
2 | import { ensureDir } from 'fs-extra/esm' | ||
3 | import { Client as NetIPC } from 'net-ipc' | ||
4 | import { ConfigManager } from '../config-manager.js' | ||
5 | import { IPCReponse, IPCReponseData, IPCRequest } from './shared/index.js' | ||
6 | |||
7 | export class IPCClient { | ||
8 | private netIPC: NetIPC | ||
9 | |||
10 | async run () { | ||
11 | await ensureDir(ConfigManager.Instance.getSocketDirectory()) | ||
12 | |||
13 | const socketPath = ConfigManager.Instance.getSocketPath() | ||
14 | |||
15 | this.netIPC = new NetIPC({ path: socketPath }) | ||
16 | |||
17 | try { | ||
18 | await this.netIPC.connect() | ||
19 | } catch (err) { | ||
20 | if (err.code === 'ECONNREFUSED') { | ||
21 | throw new Error( | ||
22 | 'This runner is not currently running in server mode on this system. ' + | ||
23 | 'Please run it using the `server` command first (in another terminal for example) and then retry your command.' | ||
24 | ) | ||
25 | } | ||
26 | |||
27 | throw err | ||
28 | } | ||
29 | } | ||
30 | |||
31 | async askRegister (options: { | ||
32 | url: string | ||
33 | registrationToken: string | ||
34 | runnerName: string | ||
35 | runnerDescription?: string | ||
36 | }) { | ||
37 | const req: IPCRequest = { | ||
38 | type: 'register', | ||
39 | ...options | ||
40 | } | ||
41 | |||
42 | const { success, error } = await this.netIPC.request(req) as IPCReponse | ||
43 | |||
44 | if (success) console.log('PeerTube instance registered') | ||
45 | else console.error('Could not register PeerTube instance on runner server side', error) | ||
46 | } | ||
47 | |||
48 | async askUnregister (options: { | ||
49 | url: string | ||
50 | runnerName: string | ||
51 | }) { | ||
52 | const req: IPCRequest = { | ||
53 | type: 'unregister', | ||
54 | ...options | ||
55 | } | ||
56 | |||
57 | const { success, error } = await this.netIPC.request(req) as IPCReponse | ||
58 | |||
59 | if (success) console.log('PeerTube instance unregistered') | ||
60 | else console.error('Could not unregister PeerTube instance on runner server side', error) | ||
61 | } | ||
62 | |||
63 | async askListRegistered () { | ||
64 | const req: IPCRequest = { | ||
65 | type: 'list-registered' | ||
66 | } | ||
67 | |||
68 | const { success, error, data } = await this.netIPC.request(req) as IPCReponse<IPCReponseData> | ||
69 | if (!success) { | ||
70 | console.error('Could not list registered PeerTube instances', error) | ||
71 | return | ||
72 | } | ||
73 | |||
74 | const table = new CliTable3({ | ||
75 | head: [ 'instance', 'runner name', 'runner description' ] | ||
76 | }) | ||
77 | |||
78 | for (const server of data.servers) { | ||
79 | table.push([ server.url, server.runnerName, server.runnerDescription ]) | ||
80 | } | ||
81 | |||
82 | console.log(table.toString()) | ||
83 | } | ||
84 | |||
85 | stop () { | ||
86 | this.netIPC.destroy() | ||
87 | } | ||
88 | } | ||
diff --git a/apps/peertube-runner/src/shared/ipc/ipc-server.ts b/apps/peertube-runner/src/shared/ipc/ipc-server.ts new file mode 100644 index 000000000..c68438504 --- /dev/null +++ b/apps/peertube-runner/src/shared/ipc/ipc-server.ts | |||
@@ -0,0 +1,61 @@ | |||
1 | import { ensureDir } from 'fs-extra/esm' | ||
2 | import { Server as NetIPC } from 'net-ipc' | ||
3 | import { pick } from '@peertube/peertube-core-utils' | ||
4 | import { RunnerServer } from '../../server/index.js' | ||
5 | import { ConfigManager } from '../config-manager.js' | ||
6 | import { logger } from '../logger.js' | ||
7 | import { IPCReponse, IPCReponseData, IPCRequest } from './shared/index.js' | ||
8 | |||
9 | export class IPCServer { | ||
10 | private netIPC: NetIPC | ||
11 | private runnerServer: RunnerServer | ||
12 | |||
13 | async run (runnerServer: RunnerServer) { | ||
14 | this.runnerServer = runnerServer | ||
15 | |||
16 | await ensureDir(ConfigManager.Instance.getSocketDirectory()) | ||
17 | |||
18 | const socketPath = ConfigManager.Instance.getSocketPath() | ||
19 | this.netIPC = new NetIPC({ path: socketPath }) | ||
20 | await this.netIPC.start() | ||
21 | |||
22 | logger.info(`IPC socket created on ${socketPath}`) | ||
23 | |||
24 | this.netIPC.on('request', async (req: IPCRequest, res) => { | ||
25 | try { | ||
26 | const data = await this.process(req) | ||
27 | |||
28 | this.sendReponse(res, { success: true, data }) | ||
29 | } catch (err) { | ||
30 | logger.error('Cannot execute RPC call', err) | ||
31 | this.sendReponse(res, { success: false, error: err.message }) | ||
32 | } | ||
33 | }) | ||
34 | } | ||
35 | |||
36 | private async process (req: IPCRequest) { | ||
37 | switch (req.type) { | ||
38 | case 'register': | ||
39 | await this.runnerServer.registerRunner(pick(req, [ 'url', 'registrationToken', 'runnerName', 'runnerDescription' ])) | ||
40 | return undefined | ||
41 | |||
42 | case 'unregister': | ||
43 | await this.runnerServer.unregisterRunner(pick(req, [ 'url', 'runnerName' ])) | ||
44 | return undefined | ||
45 | |||
46 | case 'list-registered': | ||
47 | return Promise.resolve(this.runnerServer.listRegistered()) | ||
48 | |||
49 | default: | ||
50 | throw new Error('Unknown RPC call ' + (req as any).type) | ||
51 | } | ||
52 | } | ||
53 | |||
54 | private sendReponse <T extends IPCReponseData> ( | ||
55 | response: (data: any) => Promise<void>, | ||
56 | body: IPCReponse<T> | ||
57 | ) { | ||
58 | response(body) | ||
59 | .catch(err => logger.error('Cannot send response after IPC request', err)) | ||
60 | } | ||
61 | } | ||
diff --git a/apps/peertube-runner/src/shared/ipc/shared/index.ts b/apps/peertube-runner/src/shared/ipc/shared/index.ts new file mode 100644 index 000000000..986acafb0 --- /dev/null +++ b/apps/peertube-runner/src/shared/ipc/shared/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './ipc-request.model.js' | ||
2 | export * from './ipc-response.model.js' | ||
diff --git a/apps/peertube-runner/src/shared/ipc/shared/ipc-request.model.ts b/apps/peertube-runner/src/shared/ipc/shared/ipc-request.model.ts new file mode 100644 index 000000000..352808c74 --- /dev/null +++ b/apps/peertube-runner/src/shared/ipc/shared/ipc-request.model.ts | |||
@@ -0,0 +1,15 @@ | |||
1 | export type IPCRequest = | ||
2 | IPCRequestRegister | | ||
3 | IPCRequestUnregister | | ||
4 | IPCRequestListRegistered | ||
5 | |||
6 | export type IPCRequestRegister = { | ||
7 | type: 'register' | ||
8 | url: string | ||
9 | registrationToken: string | ||
10 | runnerName: string | ||
11 | runnerDescription?: string | ||
12 | } | ||
13 | |||
14 | export type IPCRequestUnregister = { type: 'unregister', url: string, runnerName: string } | ||
15 | export type IPCRequestListRegistered = { type: 'list-registered' } | ||
diff --git a/apps/peertube-runner/src/shared/ipc/shared/ipc-response.model.ts b/apps/peertube-runner/src/shared/ipc/shared/ipc-response.model.ts new file mode 100644 index 000000000..689d6e09a --- /dev/null +++ b/apps/peertube-runner/src/shared/ipc/shared/ipc-response.model.ts | |||
@@ -0,0 +1,15 @@ | |||
1 | export type IPCReponse <T extends IPCReponseData = undefined> = { | ||
2 | success: boolean | ||
3 | error?: string | ||
4 | data?: T | ||
5 | } | ||
6 | |||
7 | export type IPCReponseData = | ||
8 | // list registered | ||
9 | { | ||
10 | servers: { | ||
11 | runnerName: string | ||
12 | runnerDescription: string | ||
13 | url: string | ||
14 | }[] | ||
15 | } | ||
diff --git a/apps/peertube-runner/src/shared/logger.ts b/apps/peertube-runner/src/shared/logger.ts new file mode 100644 index 000000000..ef5283892 --- /dev/null +++ b/apps/peertube-runner/src/shared/logger.ts | |||
@@ -0,0 +1,12 @@ | |||
1 | import { pino } from 'pino' | ||
2 | import pretty from 'pino-pretty' | ||
3 | |||
4 | const logger = pino(pretty.default({ | ||
5 | colorize: true | ||
6 | })) | ||
7 | |||
8 | logger.level = 'info' | ||
9 | |||
10 | export { | ||
11 | logger | ||
12 | } | ||
diff --git a/apps/peertube-runner/tsconfig.json b/apps/peertube-runner/tsconfig.json new file mode 100644 index 000000000..03660b0eb --- /dev/null +++ b/apps/peertube-runner/tsconfig.json | |||
@@ -0,0 +1,16 @@ | |||
1 | { | ||
2 | "extends": "../../tsconfig.base.json", | ||
3 | "compilerOptions": { | ||
4 | "baseUrl": "./", | ||
5 | "outDir": "./dist", | ||
6 | "rootDir": "src", | ||
7 | "tsBuildInfoFile": "./dist/.tsbuildinfo" | ||
8 | }, | ||
9 | "references": [ | ||
10 | { "path": "../../packages/core-utils" }, | ||
11 | { "path": "../../packages/ffmpeg" }, | ||
12 | { "path": "../../packages/models" }, | ||
13 | { "path": "../../packages/node-utils" }, | ||
14 | { "path": "../../packages/server-commands" } | ||
15 | ] | ||
16 | } | ||
diff --git a/apps/peertube-runner/yarn.lock b/apps/peertube-runner/yarn.lock new file mode 100644 index 000000000..adb5aa118 --- /dev/null +++ b/apps/peertube-runner/yarn.lock | |||
@@ -0,0 +1,528 @@ | |||
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. | ||
2 | # yarn lockfile v1 | ||
3 | |||
4 | |||
5 | "@commander-js/extra-typings@^10.0.3": | ||
6 | version "10.0.3" | ||
7 | resolved "https://registry.yarnpkg.com/@commander-js/extra-typings/-/extra-typings-10.0.3.tgz#8b6c64897231ed9c00461db82018b5131b653aae" | ||
8 | integrity sha512-OIw28QV/GlP8k0B5CJTRsl8IyNvd0R8C8rfo54Yz9P388vCNDgdNrFlKxZTGqps+5j6lSw3Ss9JTQwcur1w1oA== | ||
9 | |||
10 | "@esbuild/android-arm64@0.17.15": | ||
11 | version "0.17.15" | ||
12 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.15.tgz#893ad71f3920ccb919e1757c387756a9bca2ef42" | ||
13 | integrity sha512-0kOB6Y7Br3KDVgHeg8PRcvfLkq+AccreK///B4Z6fNZGr/tNHX0z2VywCc7PTeWp+bPvjA5WMvNXltHw5QjAIA== | ||
14 | |||
15 | "@esbuild/android-arm@0.17.15": | ||
16 | version "0.17.15" | ||
17 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.15.tgz#143e0d4e4c08c786ea410b9a7739779a9a1315d8" | ||
18 | integrity sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg== | ||
19 | |||
20 | "@esbuild/android-x64@0.17.15": | ||
21 | version "0.17.15" | ||
22 | resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.15.tgz#d2d12a7676b2589864281b2274355200916540bc" | ||
23 | integrity sha512-MzDqnNajQZ63YkaUWVl9uuhcWyEyh69HGpMIrf+acR4otMkfLJ4sUCxqwbCyPGicE9dVlrysI3lMcDBjGiBBcQ== | ||
24 | |||
25 | "@esbuild/darwin-arm64@0.17.15": | ||
26 | version "0.17.15" | ||
27 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.15.tgz#2e88e79f1d327a2a7d9d06397e5232eb0a473d61" | ||
28 | integrity sha512-7siLjBc88Z4+6qkMDxPT2juf2e8SJxmsbNVKFY2ifWCDT72v5YJz9arlvBw5oB4W/e61H1+HDB/jnu8nNg0rLA== | ||
29 | |||
30 | "@esbuild/darwin-x64@0.17.15": | ||
31 | version "0.17.15" | ||
32 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz#9384e64c0be91388c57be6d3a5eaf1c32a99c91d" | ||
33 | integrity sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg== | ||
34 | |||
35 | "@esbuild/freebsd-arm64@0.17.15": | ||
36 | version "0.17.15" | ||
37 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.15.tgz#2ad5a35bc52ebd9ca6b845dbc59ba39647a93c1a" | ||
38 | integrity sha512-Xk9xMDjBVG6CfgoqlVczHAdJnCs0/oeFOspFap5NkYAmRCT2qTn1vJWA2f419iMtsHSLm+O8B6SLV/HlY5cYKg== | ||
39 | |||
40 | "@esbuild/freebsd-x64@0.17.15": | ||
41 | version "0.17.15" | ||
42 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.15.tgz#b513a48446f96c75fda5bef470e64d342d4379cd" | ||
43 | integrity sha512-3TWAnnEOdclvb2pnfsTWtdwthPfOz7qAfcwDLcfZyGJwm1SRZIMOeB5FODVhnM93mFSPsHB9b/PmxNNbSnd0RQ== | ||
44 | |||
45 | "@esbuild/linux-arm64@0.17.15": | ||
46 | version "0.17.15" | ||
47 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.15.tgz#9697b168175bfd41fa9cc4a72dd0d48f24715f31" | ||
48 | integrity sha512-T0MVnYw9KT6b83/SqyznTs/3Jg2ODWrZfNccg11XjDehIved2oQfrX/wVuev9N936BpMRaTR9I1J0tdGgUgpJA== | ||
49 | |||
50 | "@esbuild/linux-arm@0.17.15": | ||
51 | version "0.17.15" | ||
52 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.15.tgz#5b22062c54f48cd92fab9ffd993732a52db70cd3" | ||
53 | integrity sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw== | ||
54 | |||
55 | "@esbuild/linux-ia32@0.17.15": | ||
56 | version "0.17.15" | ||
57 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.15.tgz#eb28a13f9b60b5189fcc9e98e1024f6b657ba54c" | ||
58 | integrity sha512-wp02sHs015T23zsQtU4Cj57WiteiuASHlD7rXjKUyAGYzlOKDAjqK6bk5dMi2QEl/KVOcsjwL36kD+WW7vJt8Q== | ||
59 | |||
60 | "@esbuild/linux-loong64@0.17.15": | ||
61 | version "0.17.15" | ||
62 | resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.15.tgz#32454bdfe144cf74b77895a8ad21a15cb81cfbe5" | ||
63 | integrity sha512-k7FsUJjGGSxwnBmMh8d7IbObWu+sF/qbwc+xKZkBe/lTAF16RqxRCnNHA7QTd3oS2AfGBAnHlXL67shV5bBThQ== | ||
64 | |||
65 | "@esbuild/linux-mips64el@0.17.15": | ||
66 | version "0.17.15" | ||
67 | resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.15.tgz#af12bde0d775a318fad90eb13a0455229a63987c" | ||
68 | integrity sha512-ZLWk6czDdog+Q9kE/Jfbilu24vEe/iW/Sj2d8EVsmiixQ1rM2RKH2n36qfxK4e8tVcaXkvuV3mU5zTZviE+NVQ== | ||
69 | |||
70 | "@esbuild/linux-ppc64@0.17.15": | ||
71 | version "0.17.15" | ||
72 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.15.tgz#34c5ed145b2dfc493d3e652abac8bd3baa3865a5" | ||
73 | integrity sha512-mY6dPkIRAiFHRsGfOYZC8Q9rmr8vOBZBme0/j15zFUKM99d4ILY4WpOC7i/LqoY+RE7KaMaSfvY8CqjJtuO4xg== | ||
74 | |||
75 | "@esbuild/linux-riscv64@0.17.15": | ||
76 | version "0.17.15" | ||
77 | resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.15.tgz#87bd515e837f2eb004b45f9e6a94dc5b93f22b92" | ||
78 | integrity sha512-EcyUtxffdDtWjjwIH8sKzpDRLcVtqANooMNASO59y+xmqqRYBBM7xVLQhqF7nksIbm2yHABptoioS9RAbVMWVA== | ||
79 | |||
80 | "@esbuild/linux-s390x@0.17.15": | ||
81 | version "0.17.15" | ||
82 | resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.15.tgz#20bf7947197f199ddac2ec412029a414ceae3aa3" | ||
83 | integrity sha512-BuS6Jx/ezxFuHxgsfvz7T4g4YlVrmCmg7UAwboeyNNg0OzNzKsIZXpr3Sb/ZREDXWgt48RO4UQRDBxJN3B9Rbg== | ||
84 | |||
85 | "@esbuild/linux-x64@0.17.15": | ||
86 | version "0.17.15" | ||
87 | resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.15.tgz#31b93f9c94c195e852c20cd3d1914a68aa619124" | ||
88 | integrity sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg== | ||
89 | |||
90 | "@esbuild/netbsd-x64@0.17.15": | ||
91 | version "0.17.15" | ||
92 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.15.tgz#8da299b3ac6875836ca8cdc1925826498069ac65" | ||
93 | integrity sha512-R6fKjtUysYGym6uXf6qyNephVUQAGtf3n2RCsOST/neIwPqRWcnc3ogcielOd6pT+J0RDR1RGcy0ZY7d3uHVLA== | ||
94 | |||
95 | "@esbuild/openbsd-x64@0.17.15": | ||
96 | version "0.17.15" | ||
97 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.15.tgz#04a1ec3d4e919714dba68dcf09eeb1228ad0d20c" | ||
98 | integrity sha512-mVD4PGc26b8PI60QaPUltYKeSX0wxuy0AltC+WCTFwvKCq2+OgLP4+fFd+hZXzO2xW1HPKcytZBdjqL6FQFa7w== | ||
99 | |||
100 | "@esbuild/sunos-x64@0.17.15": | ||
101 | version "0.17.15" | ||
102 | resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.15.tgz#6694ebe4e16e5cd7dab6505ff7c28f9c1c695ce5" | ||
103 | integrity sha512-U6tYPovOkw3459t2CBwGcFYfFRjivcJJc1WC8Q3funIwX8x4fP+R6xL/QuTPNGOblbq/EUDxj9GU+dWKX0oWlQ== | ||
104 | |||
105 | "@esbuild/win32-arm64@0.17.15": | ||
106 | version "0.17.15" | ||
107 | resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.15.tgz#1f95b2564193c8d1fee8f8129a0609728171d500" | ||
108 | integrity sha512-W+Z5F++wgKAleDABemiyXVnzXgvRFs+GVKThSI+mGgleLWluv0D7Diz4oQpgdpNzh4i2nNDzQtWbjJiqutRp6Q== | ||
109 | |||
110 | "@esbuild/win32-ia32@0.17.15": | ||
111 | version "0.17.15" | ||
112 | resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.15.tgz#c362b88b3df21916ed7bcf75c6d09c6bf3ae354a" | ||
113 | integrity sha512-Muz/+uGgheShKGqSVS1KsHtCyEzcdOn/W/Xbh6H91Etm+wiIfwZaBn1W58MeGtfI8WA961YMHFYTthBdQs4t+w== | ||
114 | |||
115 | "@esbuild/win32-x64@0.17.15": | ||
116 | version "0.17.15" | ||
117 | resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.15.tgz#c2e737f3a201ebff8e2ac2b8e9f246b397ad19b8" | ||
118 | integrity sha512-DjDa9ywLUUmjhV2Y9wUTIF+1XsmuFGvZoCmOWkli1XcNAh5t25cc7fgsCx4Zi/Uurep3TTLyDiKATgGEg61pkA== | ||
119 | |||
120 | "@iarna/toml@^2.2.5": | ||
121 | version "2.2.5" | ||
122 | resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c" | ||
123 | integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg== | ||
124 | |||
125 | "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2": | ||
126 | version "3.0.2" | ||
127 | resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz#44d752c1a2dc113f15f781b7cc4f53a307e3fa38" | ||
128 | integrity sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ== | ||
129 | |||
130 | "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.2": | ||
131 | version "3.0.2" | ||
132 | resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz#f954f34355712212a8e06c465bc06c40852c6bb3" | ||
133 | integrity sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw== | ||
134 | |||
135 | "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.2": | ||
136 | version "3.0.2" | ||
137 | resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz#45c63037f045c2b15c44f80f0393fa24f9655367" | ||
138 | integrity sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg== | ||
139 | |||
140 | "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.2": | ||
141 | version "3.0.2" | ||
142 | resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz#35707efeafe6d22b3f373caf9e8775e8920d1399" | ||
143 | integrity sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA== | ||
144 | |||
145 | "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.2": | ||
146 | version "3.0.2" | ||
147 | resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz#091b1218b66c341f532611477ef89e83f25fae4f" | ||
148 | integrity sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA== | ||
149 | |||
150 | "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.2": | ||
151 | version "3.0.2" | ||
152 | resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz#0f164b726869f71da3c594171df5ebc1c4b0a407" | ||
153 | integrity sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ== | ||
154 | |||
155 | abort-controller@^3.0.0: | ||
156 | version "3.0.0" | ||
157 | resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" | ||
158 | integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== | ||
159 | dependencies: | ||
160 | event-target-shim "^5.0.0" | ||
161 | |||
162 | atomic-sleep@^1.0.0: | ||
163 | version "1.0.0" | ||
164 | resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" | ||
165 | integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== | ||
166 | |||
167 | balanced-match@^1.0.0: | ||
168 | version "1.0.2" | ||
169 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" | ||
170 | integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== | ||
171 | |||
172 | base64-js@^1.3.1: | ||
173 | version "1.5.1" | ||
174 | resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" | ||
175 | integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== | ||
176 | |||
177 | brace-expansion@^2.0.1: | ||
178 | version "2.0.1" | ||
179 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" | ||
180 | integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== | ||
181 | dependencies: | ||
182 | balanced-match "^1.0.0" | ||
183 | |||
184 | buffer@^6.0.3: | ||
185 | version "6.0.3" | ||
186 | resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" | ||
187 | integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== | ||
188 | dependencies: | ||
189 | base64-js "^1.3.1" | ||
190 | ieee754 "^1.2.1" | ||
191 | |||
192 | colorette@^2.0.7: | ||
193 | version "2.0.19" | ||
194 | resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" | ||
195 | integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== | ||
196 | |||
197 | dateformat@^4.6.3: | ||
198 | version "4.6.3" | ||
199 | resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5" | ||
200 | integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== | ||
201 | |||
202 | end-of-stream@^1.1.0: | ||
203 | version "1.4.4" | ||
204 | resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" | ||
205 | integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== | ||
206 | dependencies: | ||
207 | once "^1.4.0" | ||
208 | |||
209 | env-paths@^3.0.0: | ||
210 | version "3.0.0" | ||
211 | resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-3.0.0.tgz#2f1e89c2f6dbd3408e1b1711dd82d62e317f58da" | ||
212 | integrity sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A== | ||
213 | |||
214 | esbuild@^0.17.15: | ||
215 | version "0.17.15" | ||
216 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.15.tgz#209ebc87cb671ffb79574db93494b10ffaf43cbc" | ||
217 | integrity sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw== | ||
218 | optionalDependencies: | ||
219 | "@esbuild/android-arm" "0.17.15" | ||
220 | "@esbuild/android-arm64" "0.17.15" | ||
221 | "@esbuild/android-x64" "0.17.15" | ||
222 | "@esbuild/darwin-arm64" "0.17.15" | ||
223 | "@esbuild/darwin-x64" "0.17.15" | ||
224 | "@esbuild/freebsd-arm64" "0.17.15" | ||
225 | "@esbuild/freebsd-x64" "0.17.15" | ||
226 | "@esbuild/linux-arm" "0.17.15" | ||
227 | "@esbuild/linux-arm64" "0.17.15" | ||
228 | "@esbuild/linux-ia32" "0.17.15" | ||
229 | "@esbuild/linux-loong64" "0.17.15" | ||
230 | "@esbuild/linux-mips64el" "0.17.15" | ||
231 | "@esbuild/linux-ppc64" "0.17.15" | ||
232 | "@esbuild/linux-riscv64" "0.17.15" | ||
233 | "@esbuild/linux-s390x" "0.17.15" | ||
234 | "@esbuild/linux-x64" "0.17.15" | ||
235 | "@esbuild/netbsd-x64" "0.17.15" | ||
236 | "@esbuild/openbsd-x64" "0.17.15" | ||
237 | "@esbuild/sunos-x64" "0.17.15" | ||
238 | "@esbuild/win32-arm64" "0.17.15" | ||
239 | "@esbuild/win32-ia32" "0.17.15" | ||
240 | "@esbuild/win32-x64" "0.17.15" | ||
241 | |||
242 | event-target-shim@^5.0.0: | ||
243 | version "5.0.1" | ||
244 | resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" | ||
245 | integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== | ||
246 | |||
247 | events@^3.3.0: | ||
248 | version "3.3.0" | ||
249 | resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" | ||
250 | integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== | ||
251 | |||
252 | fast-copy@^3.0.0: | ||
253 | version "3.0.1" | ||
254 | resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.1.tgz#9e89ef498b8c04c1cd76b33b8e14271658a732aa" | ||
255 | integrity sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA== | ||
256 | |||
257 | fast-redact@^3.1.1: | ||
258 | version "3.1.2" | ||
259 | resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa" | ||
260 | integrity sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw== | ||
261 | |||
262 | fast-safe-stringify@^2.1.1: | ||
263 | version "2.1.1" | ||
264 | resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" | ||
265 | integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== | ||
266 | |||
267 | fast-zlib@^2.0.1: | ||
268 | version "2.0.1" | ||
269 | resolved "https://registry.yarnpkg.com/fast-zlib/-/fast-zlib-2.0.1.tgz#be624f592fc80ad8019ee2025d16a367a4e9b024" | ||
270 | integrity sha512-DCoYgNagM2Bt1VIpXpdGnRx4LzqJeYG0oh6Nf/7cWo6elTXkFGMw9CrRCYYUIapYNrozYMoyDRflx9mgT3Awyw== | ||
271 | |||
272 | fs.realpath@^1.0.0: | ||
273 | version "1.0.0" | ||
274 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" | ||
275 | integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== | ||
276 | |||
277 | glob@^8.0.0: | ||
278 | version "8.1.0" | ||
279 | resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" | ||
280 | integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== | ||
281 | dependencies: | ||
282 | fs.realpath "^1.0.0" | ||
283 | inflight "^1.0.4" | ||
284 | inherits "2" | ||
285 | minimatch "^5.0.1" | ||
286 | once "^1.3.0" | ||
287 | |||
288 | help-me@^4.0.1: | ||
289 | version "4.2.0" | ||
290 | resolved "https://registry.yarnpkg.com/help-me/-/help-me-4.2.0.tgz#50712bfd799ff1854ae1d312c36eafcea85b0563" | ||
291 | integrity sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA== | ||
292 | dependencies: | ||
293 | glob "^8.0.0" | ||
294 | readable-stream "^3.6.0" | ||
295 | |||
296 | ieee754@^1.2.1: | ||
297 | version "1.2.1" | ||
298 | resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" | ||
299 | integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== | ||
300 | |||
301 | inflight@^1.0.4: | ||
302 | version "1.0.6" | ||
303 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" | ||
304 | integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== | ||
305 | dependencies: | ||
306 | once "^1.3.0" | ||
307 | wrappy "1" | ||
308 | |||
309 | inherits@2, inherits@^2.0.3: | ||
310 | version "2.0.4" | ||
311 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" | ||
312 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== | ||
313 | |||
314 | joycon@^3.1.1: | ||
315 | version "3.1.1" | ||
316 | resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" | ||
317 | integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== | ||
318 | |||
319 | minimatch@^5.0.1: | ||
320 | version "5.1.6" | ||
321 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" | ||
322 | integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== | ||
323 | dependencies: | ||
324 | brace-expansion "^2.0.1" | ||
325 | |||
326 | minimist@^1.2.6: | ||
327 | version "1.2.8" | ||
328 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" | ||
329 | integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== | ||
330 | |||
331 | msgpackr-extract@^3.0.1: | ||
332 | version "3.0.2" | ||
333 | resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.2.tgz#e05ec1bb4453ddf020551bcd5daaf0092a2c279d" | ||
334 | integrity sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A== | ||
335 | dependencies: | ||
336 | node-gyp-build-optional-packages "5.0.7" | ||
337 | optionalDependencies: | ||
338 | "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.2" | ||
339 | "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.2" | ||
340 | "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.2" | ||
341 | "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.2" | ||
342 | "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.2" | ||
343 | "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.2" | ||
344 | |||
345 | msgpackr@^1.3.2: | ||
346 | version "1.8.5" | ||
347 | resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.8.5.tgz#8cadfb935357680648f33699d0e833c9179dbfeb" | ||
348 | integrity sha512-mpPs3qqTug6ahbblkThoUY2DQdNXcm4IapwOS3Vm/87vmpzLVelvp9h3It1y9l1VPpiFLV11vfOXnmeEwiIXwg== | ||
349 | optionalDependencies: | ||
350 | msgpackr-extract "^3.0.1" | ||
351 | |||
352 | net-ipc@^2.0.1: | ||
353 | version "2.0.1" | ||
354 | resolved "https://registry.yarnpkg.com/net-ipc/-/net-ipc-2.0.1.tgz#1da79ca16f1624f2ed1099a124cb065912c595a5" | ||
355 | integrity sha512-4HLjZ/Xorj4kxA7WUajF2EAXlS+OR+XliDLkqQA53Wm7eIr/hWLjdXt4zzB6q4Ii8BB+HbuRbM9yLov3+ttRUw== | ||
356 | optionalDependencies: | ||
357 | fast-zlib "^2.0.1" | ||
358 | msgpackr "^1.3.2" | ||
359 | |||
360 | node-gyp-build-optional-packages@5.0.7: | ||
361 | version "5.0.7" | ||
362 | resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz#5d2632bbde0ab2f6e22f1bbac2199b07244ae0b3" | ||
363 | integrity sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w== | ||
364 | |||
365 | on-exit-leak-free@^2.1.0: | ||
366 | version "2.1.0" | ||
367 | resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz#5c703c968f7e7f851885f6459bf8a8a57edc9cc4" | ||
368 | integrity sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w== | ||
369 | |||
370 | once@^1.3.0, once@^1.3.1, once@^1.4.0: | ||
371 | version "1.4.0" | ||
372 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" | ||
373 | integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== | ||
374 | dependencies: | ||
375 | wrappy "1" | ||
376 | |||
377 | pino-abstract-transport@^1.0.0, pino-abstract-transport@v1.0.0: | ||
378 | version "1.0.0" | ||
379 | resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz#cc0d6955fffcadb91b7b49ef220a6cc111d48bb3" | ||
380 | integrity sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA== | ||
381 | dependencies: | ||
382 | readable-stream "^4.0.0" | ||
383 | split2 "^4.0.0" | ||
384 | |||
385 | pino-pretty@^10.0.0: | ||
386 | version "10.0.0" | ||
387 | resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-10.0.0.tgz#fd2f307ee897289f63d09b0b804ac2ecc9a18516" | ||
388 | integrity sha512-zKFjYXBzLaLTEAN1ayKpHXtL5UeRQC7R3lvhKe7fWs7hIVEjKGG/qIXwQt9HmeUp71ogUd/YcW+LmMwRp4KT6Q== | ||
389 | dependencies: | ||
390 | colorette "^2.0.7" | ||
391 | dateformat "^4.6.3" | ||
392 | fast-copy "^3.0.0" | ||
393 | fast-safe-stringify "^2.1.1" | ||
394 | help-me "^4.0.1" | ||
395 | joycon "^3.1.1" | ||
396 | minimist "^1.2.6" | ||
397 | on-exit-leak-free "^2.1.0" | ||
398 | pino-abstract-transport "^1.0.0" | ||
399 | pump "^3.0.0" | ||
400 | readable-stream "^4.0.0" | ||
401 | secure-json-parse "^2.4.0" | ||
402 | sonic-boom "^3.0.0" | ||
403 | strip-json-comments "^3.1.1" | ||
404 | |||
405 | pino-std-serializers@^6.0.0: | ||
406 | version "6.2.0" | ||
407 | resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-6.2.0.tgz#169048c0df3f61352fce56aeb7fb962f1b66ab43" | ||
408 | integrity sha512-IWgSzUL8X1w4BIWTwErRgtV8PyOGOOi60uqv0oKuS/fOA8Nco/OeI6lBuc4dyP8MMfdFwyHqTMcBIA7nDiqEqA== | ||
409 | |||
410 | pino@^8.11.0: | ||
411 | version "8.11.0" | ||
412 | resolved "https://registry.yarnpkg.com/pino/-/pino-8.11.0.tgz#2a91f454106b13e708a66c74ebc1c2ab7ab38498" | ||
413 | integrity sha512-Z2eKSvlrl2rH8p5eveNUnTdd4AjJk8tAsLkHYZQKGHP4WTh2Gi1cOSOs3eWPqaj+niS3gj4UkoreoaWgF3ZWYg== | ||
414 | dependencies: | ||
415 | atomic-sleep "^1.0.0" | ||
416 | fast-redact "^3.1.1" | ||
417 | on-exit-leak-free "^2.1.0" | ||
418 | pino-abstract-transport v1.0.0 | ||
419 | pino-std-serializers "^6.0.0" | ||
420 | process-warning "^2.0.0" | ||
421 | quick-format-unescaped "^4.0.3" | ||
422 | real-require "^0.2.0" | ||
423 | safe-stable-stringify "^2.3.1" | ||
424 | sonic-boom "^3.1.0" | ||
425 | thread-stream "^2.0.0" | ||
426 | |||
427 | process-warning@^2.0.0: | ||
428 | version "2.2.0" | ||
429 | resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.2.0.tgz#008ec76b579820a8e5c35d81960525ca64feb626" | ||
430 | integrity sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg== | ||
431 | |||
432 | process@^0.11.10: | ||
433 | version "0.11.10" | ||
434 | resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" | ||
435 | integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== | ||
436 | |||
437 | pump@^3.0.0: | ||
438 | version "3.0.0" | ||
439 | resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" | ||
440 | integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== | ||
441 | dependencies: | ||
442 | end-of-stream "^1.1.0" | ||
443 | once "^1.3.1" | ||
444 | |||
445 | quick-format-unescaped@^4.0.3: | ||
446 | version "4.0.4" | ||
447 | resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" | ||
448 | integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== | ||
449 | |||
450 | readable-stream@^3.6.0: | ||
451 | version "3.6.2" | ||
452 | resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" | ||
453 | integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== | ||
454 | dependencies: | ||
455 | inherits "^2.0.3" | ||
456 | string_decoder "^1.1.1" | ||
457 | util-deprecate "^1.0.1" | ||
458 | |||
459 | readable-stream@^4.0.0: | ||
460 | version "4.3.0" | ||
461 | resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.3.0.tgz#0914d0c72db03b316c9733bb3461d64a3cc50cba" | ||
462 | integrity sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ== | ||
463 | dependencies: | ||
464 | abort-controller "^3.0.0" | ||
465 | buffer "^6.0.3" | ||
466 | events "^3.3.0" | ||
467 | process "^0.11.10" | ||
468 | |||
469 | real-require@^0.2.0: | ||
470 | version "0.2.0" | ||
471 | resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" | ||
472 | integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== | ||
473 | |||
474 | safe-buffer@~5.2.0: | ||
475 | version "5.2.1" | ||
476 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" | ||
477 | integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== | ||
478 | |||
479 | safe-stable-stringify@^2.3.1: | ||
480 | version "2.4.3" | ||
481 | resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886" | ||
482 | integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g== | ||
483 | |||
484 | secure-json-parse@^2.4.0: | ||
485 | version "2.7.0" | ||
486 | resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" | ||
487 | integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== | ||
488 | |||
489 | sonic-boom@^3.0.0, sonic-boom@^3.1.0: | ||
490 | version "3.3.0" | ||
491 | resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-3.3.0.tgz#cffab6dafee3b2bcb88d08d589394198bee1838c" | ||
492 | integrity sha512-LYxp34KlZ1a2Jb8ZQgFCK3niIHzibdwtwNUWKg0qQRzsDoJ3Gfgkf8KdBTFU3SkejDEIlWwnSnpVdOZIhFMl/g== | ||
493 | dependencies: | ||
494 | atomic-sleep "^1.0.0" | ||
495 | |||
496 | split2@^4.0.0: | ||
497 | version "4.2.0" | ||
498 | resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" | ||
499 | integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== | ||
500 | |||
501 | string_decoder@^1.1.1: | ||
502 | version "1.3.0" | ||
503 | resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" | ||
504 | integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== | ||
505 | dependencies: | ||
506 | safe-buffer "~5.2.0" | ||
507 | |||
508 | strip-json-comments@^3.1.1: | ||
509 | version "3.1.1" | ||
510 | resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" | ||
511 | integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== | ||
512 | |||
513 | thread-stream@^2.0.0: | ||
514 | version "2.3.0" | ||
515 | resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.3.0.tgz#4fc07fb39eff32ae7bad803cb7dd9598349fed33" | ||
516 | integrity sha512-kaDqm1DET9pp3NXwR8382WHbnpXnRkN9xGN9dQt3B2+dmXiW8X1SOwmFOxAErEQ47ObhZ96J6yhZNXuyCOL7KA== | ||
517 | dependencies: | ||
518 | real-require "^0.2.0" | ||
519 | |||
520 | util-deprecate@^1.0.1: | ||
521 | version "1.0.2" | ||
522 | resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" | ||
523 | integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== | ||
524 | |||
525 | wrappy@1: | ||
526 | version "1.0.2" | ||
527 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" | ||
528 | integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== | ||