diff options
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | scripts/create-import-video-file-job.ts | 39 | ||||
-rw-r--r-- | server/helpers/core-utils.ts | 4 | ||||
-rw-r--r-- | server/initializers/constants.ts | 2 | ||||
-rw-r--r-- | server/lib/job-queue/handlers/video-file.ts | 29 | ||||
-rw-r--r-- | server/lib/job-queue/job-queue.ts | 4 | ||||
-rw-r--r-- | server/models/video/video.ts | 35 | ||||
-rw-r--r-- | server/tests/api/fixtures/video_short-480.webm | bin | 0 -> 69217 bytes | |||
-rw-r--r-- | server/tests/cli/create-import-video-file-job.ts | 111 | ||||
-rw-r--r-- | server/tests/cli/index.ts | 1 | ||||
-rw-r--r-- | shared/models/server/job.model.ts | 1 |
11 files changed, 221 insertions, 6 deletions
diff --git a/package.json b/package.json index 707579af3..4daeecb88 100644 --- a/package.json +++ b/package.json | |||
@@ -40,6 +40,7 @@ | |||
40 | "start": "node dist/server", | 40 | "start": "node dist/server", |
41 | "update-host": "node ./dist/scripts/update-host.js", | 41 | "update-host": "node ./dist/scripts/update-host.js", |
42 | "create-transcoding-job": "node ./dist/scripts/create-transcoding-job.js", | 42 | "create-transcoding-job": "node ./dist/scripts/create-transcoding-job.js", |
43 | "create-import-video-file-job": "node ./dist/scripts/create-import-video-file-job.js", | ||
43 | "test": "scripty", | 44 | "test": "scripty", |
44 | "help": "scripty", | 45 | "help": "scripty", |
45 | "generate-api-doc": "scripty", | 46 | "generate-api-doc": "scripty", |
diff --git a/scripts/create-import-video-file-job.ts b/scripts/create-import-video-file-job.ts new file mode 100644 index 000000000..a2f4f38f2 --- /dev/null +++ b/scripts/create-import-video-file-job.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import * as program from 'commander' | ||
2 | import { resolve } from 'path' | ||
3 | import { VideoModel } from '../server/models/video/video' | ||
4 | import { initDatabaseModels } from '../server/initializers' | ||
5 | import { JobQueue } from '../server/lib/job-queue' | ||
6 | |||
7 | program | ||
8 | .option('-v, --video [videoUUID]', 'Video UUID') | ||
9 | .option('-i, --import [videoFile]', 'Video file') | ||
10 | .description('Import a video file to replace an already uploaded file or to add a new resolution') | ||
11 | .parse(process.argv) | ||
12 | |||
13 | if (program['video'] === undefined || program['import'] === undefined) { | ||
14 | console.error('All parameters are mandatory.') | ||
15 | process.exit(-1) | ||
16 | } | ||
17 | |||
18 | run() | ||
19 | .then(() => process.exit(0)) | ||
20 | .catch(err => { | ||
21 | console.error(err) | ||
22 | process.exit(-1) | ||
23 | }) | ||
24 | |||
25 | async function run () { | ||
26 | await initDatabaseModels(true) | ||
27 | |||
28 | const video = await VideoModel.loadByUUID(program['video']) | ||
29 | if (!video) throw new Error('Video not found.') | ||
30 | |||
31 | const dataInput = { | ||
32 | videoUUID: video.uuid, | ||
33 | filePath: resolve(program['import']) | ||
34 | } | ||
35 | |||
36 | await JobQueue.Instance.init() | ||
37 | await JobQueue.Instance.createJob({ type: 'video-file-import', payload: dataInput }) | ||
38 | console.log('Import job for video %s created.', video.uuid) | ||
39 | } | ||
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts index a3dfe27b5..c560222d3 100644 --- a/server/helpers/core-utils.ts +++ b/server/helpers/core-utils.ts | |||
@@ -6,7 +6,7 @@ | |||
6 | import * as bcrypt from 'bcrypt' | 6 | import * as bcrypt from 'bcrypt' |
7 | import * as createTorrent from 'create-torrent' | 7 | import * as createTorrent from 'create-torrent' |
8 | import { pseudoRandomBytes } from 'crypto' | 8 | import { pseudoRandomBytes } from 'crypto' |
9 | import { readdir, readFile, rename, stat, Stats, unlink, writeFile } from 'fs' | 9 | import { copyFile, readdir, readFile, rename, stat, Stats, unlink, writeFile } from 'fs' |
10 | import * as mkdirp from 'mkdirp' | 10 | import * as mkdirp from 'mkdirp' |
11 | import { isAbsolute, join } from 'path' | 11 | import { isAbsolute, join } from 'path' |
12 | import * as pem from 'pem' | 12 | import * as pem from 'pem' |
@@ -136,6 +136,7 @@ function promisify2WithVoid<T, U> (func: (arg1: T, arg2: U, cb: (err: any) => vo | |||
136 | } | 136 | } |
137 | } | 137 | } |
138 | 138 | ||
139 | const copyFilePromise = promisify2WithVoid<string, string>(copyFile) | ||
139 | const readFileBufferPromise = promisify1<string, Buffer>(readFile) | 140 | const readFileBufferPromise = promisify1<string, Buffer>(readFile) |
140 | const unlinkPromise = promisify1WithVoid<string>(unlink) | 141 | const unlinkPromise = promisify1WithVoid<string>(unlink) |
141 | const renamePromise = promisify2WithVoid<string, string>(rename) | 142 | const renamePromise = promisify2WithVoid<string, string>(rename) |
@@ -167,6 +168,7 @@ export { | |||
167 | promisify0, | 168 | promisify0, |
168 | promisify1, | 169 | promisify1, |
169 | 170 | ||
171 | copyFilePromise, | ||
170 | readdirPromise, | 172 | readdirPromise, |
171 | readFileBufferPromise, | 173 | readFileBufferPromise, |
172 | unlinkPromise, | 174 | unlinkPromise, |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 9b459c241..482db2d5c 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -74,6 +74,7 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = { | |||
74 | 'activitypub-http-unicast': 5, | 74 | 'activitypub-http-unicast': 5, |
75 | 'activitypub-http-fetcher': 5, | 75 | 'activitypub-http-fetcher': 5, |
76 | 'activitypub-follow': 5, | 76 | 'activitypub-follow': 5, |
77 | 'video-file-import': 1, | ||
77 | 'video-file': 1, | 78 | 'video-file': 1, |
78 | 'email': 5 | 79 | 'email': 5 |
79 | } | 80 | } |
@@ -82,6 +83,7 @@ const JOB_CONCURRENCY: { [ id in JobType ]: number } = { | |||
82 | 'activitypub-http-unicast': 5, | 83 | 'activitypub-http-unicast': 5, |
83 | 'activitypub-http-fetcher': 1, | 84 | 'activitypub-http-fetcher': 1, |
84 | 'activitypub-follow': 3, | 85 | 'activitypub-follow': 3, |
86 | 'video-file-import': 1, | ||
85 | 'video-file': 1, | 87 | 'video-file': 1, |
86 | 'email': 5 | 88 | 'email': 5 |
87 | } | 89 | } |
diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts index 93f9e9fe7..38eb3511c 100644 --- a/server/lib/job-queue/handlers/video-file.ts +++ b/server/lib/job-queue/handlers/video-file.ts | |||
@@ -16,6 +16,28 @@ export type VideoFilePayload = { | |||
16 | isPortraitMode?: boolean | 16 | isPortraitMode?: boolean |
17 | } | 17 | } |
18 | 18 | ||
19 | export type VideoImportPayload = { | ||
20 | videoUUID: string, | ||
21 | filePath: string | ||
22 | } | ||
23 | |||
24 | async function processVideoImport (job: kue.Job) { | ||
25 | const payload = job.data as VideoImportPayload | ||
26 | logger.info('Processing video import in job %d.', job.id) | ||
27 | |||
28 | const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(payload.videoUUID) | ||
29 | // No video, maybe deleted? | ||
30 | if (!video) { | ||
31 | logger.info('Do not process job %d, video does not exist.', job.id, { videoUUID: video.uuid }) | ||
32 | return undefined | ||
33 | } | ||
34 | |||
35 | await video.importVideoFile(payload.filePath) | ||
36 | |||
37 | await onVideoFileTranscoderOrImportSuccess(video) | ||
38 | return video | ||
39 | } | ||
40 | |||
19 | async function processVideoFile (job: kue.Job) { | 41 | async function processVideoFile (job: kue.Job) { |
20 | const payload = job.data as VideoFilePayload | 42 | const payload = job.data as VideoFilePayload |
21 | logger.info('Processing video file in job %d.', job.id) | 43 | logger.info('Processing video file in job %d.', job.id) |
@@ -30,7 +52,7 @@ async function processVideoFile (job: kue.Job) { | |||
30 | // Transcoding in other resolution | 52 | // Transcoding in other resolution |
31 | if (payload.resolution) { | 53 | if (payload.resolution) { |
32 | await video.transcodeOriginalVideofile(payload.resolution, payload.isPortraitMode) | 54 | await video.transcodeOriginalVideofile(payload.resolution, payload.isPortraitMode) |
33 | await onVideoFileTranscoderSuccess(video) | 55 | await onVideoFileTranscoderOrImportSuccess(video) |
34 | } else { | 56 | } else { |
35 | await video.optimizeOriginalVideofile() | 57 | await video.optimizeOriginalVideofile() |
36 | await onVideoFileOptimizerSuccess(video, payload.isNewVideo) | 58 | await onVideoFileOptimizerSuccess(video, payload.isNewVideo) |
@@ -39,7 +61,7 @@ async function processVideoFile (job: kue.Job) { | |||
39 | return video | 61 | return video |
40 | } | 62 | } |
41 | 63 | ||
42 | async function onVideoFileTranscoderSuccess (video: VideoModel) { | 64 | async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) { |
43 | if (video === undefined) return undefined | 65 | if (video === undefined) return undefined |
44 | 66 | ||
45 | // Maybe the video changed in database, refresh it | 67 | // Maybe the video changed in database, refresh it |
@@ -109,5 +131,6 @@ async function onVideoFileOptimizerSuccess (video: VideoModel, isNewVideo: boole | |||
109 | // --------------------------------------------------------------------------- | 131 | // --------------------------------------------------------------------------- |
110 | 132 | ||
111 | export { | 133 | export { |
112 | processVideoFile | 134 | processVideoFile, |
135 | processVideoImport | ||
113 | } | 136 | } |
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 0333464bd..69335acf0 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts | |||
@@ -7,7 +7,7 @@ import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from | |||
7 | import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' | 7 | import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' |
8 | import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' | 8 | import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' |
9 | import { EmailPayload, processEmail } from './handlers/email' | 9 | import { EmailPayload, processEmail } from './handlers/email' |
10 | import { processVideoFile, VideoFilePayload } from './handlers/video-file' | 10 | import { processVideoFile, processVideoImport, VideoFilePayload, VideoImportPayload } from './handlers/video-file' |
11 | import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow' | 11 | import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow' |
12 | 12 | ||
13 | type CreateJobArgument = | 13 | type CreateJobArgument = |
@@ -15,6 +15,7 @@ type CreateJobArgument = | |||
15 | { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } | | 15 | { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } | |
16 | { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } | | 16 | { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } | |
17 | { type: 'activitypub-follow', payload: ActivitypubFollowPayload } | | 17 | { type: 'activitypub-follow', payload: ActivitypubFollowPayload } | |
18 | { type: 'video-file-import', payload: VideoImportPayload } | | ||
18 | { type: 'video-file', payload: VideoFilePayload } | | 19 | { type: 'video-file', payload: VideoFilePayload } | |
19 | { type: 'email', payload: EmailPayload } | 20 | { type: 'email', payload: EmailPayload } |
20 | 21 | ||
@@ -23,6 +24,7 @@ const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = { | |||
23 | 'activitypub-http-unicast': processActivityPubHttpUnicast, | 24 | 'activitypub-http-unicast': processActivityPubHttpUnicast, |
24 | 'activitypub-http-fetcher': processActivityPubHttpFetcher, | 25 | 'activitypub-http-fetcher': processActivityPubHttpFetcher, |
25 | 'activitypub-follow': processActivityPubFollow, | 26 | 'activitypub-follow': processActivityPubFollow, |
27 | 'video-file-import': processVideoImport, | ||
26 | 'video-file': processVideoFile, | 28 | 'video-file': processVideoFile, |
27 | 'email': processEmail | 29 | 'email': processEmail |
28 | } | 30 | } |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 5821ea397..2875e6685 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -2,7 +2,7 @@ import * as Bluebird from 'bluebird' | |||
2 | import { map, maxBy } from 'lodash' | 2 | import { map, maxBy } from 'lodash' |
3 | import * as magnetUtil from 'magnet-uri' | 3 | import * as magnetUtil from 'magnet-uri' |
4 | import * as parseTorrent from 'parse-torrent' | 4 | import * as parseTorrent from 'parse-torrent' |
5 | import { join } from 'path' | 5 | import { join, extname } from 'path' |
6 | import * as Sequelize from 'sequelize' | 6 | import * as Sequelize from 'sequelize' |
7 | import { | 7 | import { |
8 | AllowNull, | 8 | AllowNull, |
@@ -32,6 +32,7 @@ import { VideoFilter } from '../../../shared/models/videos/video-query.type' | |||
32 | import { | 32 | import { |
33 | createTorrentPromise, | 33 | createTorrentPromise, |
34 | peertubeTruncate, | 34 | peertubeTruncate, |
35 | copyFilePromise, | ||
35 | renamePromise, | 36 | renamePromise, |
36 | statPromise, | 37 | statPromise, |
37 | unlinkPromise, | 38 | unlinkPromise, |
@@ -1315,6 +1316,38 @@ export class VideoModel extends Model<VideoModel> { | |||
1315 | this.VideoFiles.push(newVideoFile) | 1316 | this.VideoFiles.push(newVideoFile) |
1316 | } | 1317 | } |
1317 | 1318 | ||
1319 | async importVideoFile (inputFilePath: string) { | ||
1320 | let updatedVideoFile = new VideoFileModel({ | ||
1321 | resolution: (await getVideoFileResolution(inputFilePath)).videoFileResolution, | ||
1322 | extname: extname(inputFilePath), | ||
1323 | size: (await statPromise(inputFilePath)).size, | ||
1324 | videoId: this.id | ||
1325 | }) | ||
1326 | |||
1327 | const outputPath = this.getVideoFilePath(updatedVideoFile) | ||
1328 | await copyFilePromise(inputFilePath, outputPath) | ||
1329 | |||
1330 | const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution) | ||
1331 | const isNewVideoFile = !currentVideoFile | ||
1332 | |||
1333 | if (!isNewVideoFile) { | ||
1334 | if (currentVideoFile.extname !== updatedVideoFile.extname) { | ||
1335 | await this.removeFile(currentVideoFile) | ||
1336 | currentVideoFile.set('extname', updatedVideoFile.extname) | ||
1337 | } | ||
1338 | currentVideoFile.set('size', updatedVideoFile.size) | ||
1339 | updatedVideoFile = currentVideoFile | ||
1340 | } | ||
1341 | |||
1342 | await this.createTorrentAndSetInfoHash(updatedVideoFile) | ||
1343 | |||
1344 | await updatedVideoFile.save() | ||
1345 | |||
1346 | if (isNewVideoFile) { | ||
1347 | this.VideoFiles.push(updatedVideoFile) | ||
1348 | } | ||
1349 | } | ||
1350 | |||
1318 | getOriginalFileResolution () { | 1351 | getOriginalFileResolution () { |
1319 | const originalFilePath = this.getVideoFilePath(this.getOriginalFile()) | 1352 | const originalFilePath = this.getVideoFilePath(this.getOriginalFile()) |
1320 | 1353 | ||
diff --git a/server/tests/api/fixtures/video_short-480.webm b/server/tests/api/fixtures/video_short-480.webm new file mode 100644 index 000000000..3145105e1 --- /dev/null +++ b/server/tests/api/fixtures/video_short-480.webm | |||
Binary files differ | |||
diff --git a/server/tests/cli/create-import-video-file-job.ts b/server/tests/cli/create-import-video-file-job.ts new file mode 100644 index 000000000..d486db600 --- /dev/null +++ b/server/tests/cli/create-import-video-file-job.ts | |||
@@ -0,0 +1,111 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import 'mocha' | ||
4 | import * as chai from 'chai' | ||
5 | import { VideoDetails, VideoFile } from '../../../shared/models/videos' | ||
6 | const expect = chai.expect | ||
7 | |||
8 | import { | ||
9 | execCLI, | ||
10 | flushTests, | ||
11 | getEnvCli, | ||
12 | getVideosList, | ||
13 | killallServers, | ||
14 | parseTorrentVideo, | ||
15 | runServer, | ||
16 | ServerInfo, | ||
17 | setAccessTokensToServers, | ||
18 | uploadVideo, | ||
19 | wait, | ||
20 | getVideo, flushAndRunMultipleServers, doubleFollow | ||
21 | } from '../utils' | ||
22 | |||
23 | function assertVideoProperties (video: VideoFile, resolution: number, extname: string) { | ||
24 | expect(video).to.have.nested.property('resolution.id', resolution) | ||
25 | expect(video).to.have.property('magnetUri').that.includes(`.${extname}`) | ||
26 | expect(video).to.have.property('torrentUrl').that.includes(`-${resolution}.torrent`) | ||
27 | expect(video).to.have.property('fileUrl').that.includes(`.${extname}`) | ||
28 | expect(video).to.have.property('size').that.is.above(0) | ||
29 | } | ||
30 | |||
31 | describe('Test create import video jobs', function () { | ||
32 | this.timeout(60000) | ||
33 | |||
34 | let servers: ServerInfo[] = [] | ||
35 | let video1UUID: string | ||
36 | let video2UUID: string | ||
37 | |||
38 | before(async function () { | ||
39 | this.timeout(90000) | ||
40 | await flushTests() | ||
41 | |||
42 | // Run server 2 to have transcoding enabled | ||
43 | servers = await flushAndRunMultipleServers(2) | ||
44 | await setAccessTokensToServers(servers) | ||
45 | |||
46 | await doubleFollow(servers[0], servers[1]) | ||
47 | |||
48 | // Upload two videos for our needs | ||
49 | const res1 = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video1' }) | ||
50 | video1UUID = res1.body.video.uuid | ||
51 | const res2 = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video2' }) | ||
52 | video2UUID = res2.body.video.uuid | ||
53 | |||
54 | await wait(40000) | ||
55 | }) | ||
56 | |||
57 | it('Should run a import job on video 1 with a lower resolution', async function () { | ||
58 | const env = getEnvCli(servers[0]) | ||
59 | await execCLI(`${env} npm run create-import-video-file-job -- -v ${video1UUID} -i server/tests/api/fixtures/video_short-480.webm`) | ||
60 | |||
61 | await wait(30000) | ||
62 | |||
63 | for (const server of servers) { | ||
64 | const { data: videos } = (await getVideosList(server.url)).body | ||
65 | expect(videos).to.have.lengthOf(2) | ||
66 | |||
67 | let infoHashes: { [ id: number ]: string } = {} | ||
68 | |||
69 | const video = videos.find(({ uuid }) => uuid === video1UUID) | ||
70 | const videoDetail: VideoDetails = (await getVideo(server.url, video.uuid)).body | ||
71 | |||
72 | expect(videoDetail.files).to.have.lengthOf(2) | ||
73 | const [originalVideo, transcodedVideo] = videoDetail.files | ||
74 | assertVideoProperties(originalVideo, 720, 'webm') | ||
75 | assertVideoProperties(transcodedVideo, 480, 'webm') | ||
76 | } | ||
77 | }) | ||
78 | |||
79 | it('Should run a import job on video 2 with the same resolution', async function () { | ||
80 | const env = getEnvCli(servers[1]) | ||
81 | await execCLI(`${env} npm run create-import-video-file-job -- -v ${video2UUID} -i server/tests/api/fixtures/video_short.ogv`) | ||
82 | |||
83 | await wait(30000) | ||
84 | |||
85 | for (const server of servers.reverse()) { | ||
86 | const { data: videos } = (await getVideosList(server.url)).body | ||
87 | expect(videos).to.have.lengthOf(2) | ||
88 | |||
89 | let infoHashes: { [ id: number ]: string } | ||
90 | |||
91 | const video = videos.find(({ uuid }) => uuid === video2UUID) | ||
92 | const videoDetail: VideoDetails = (await getVideo(server.url, video.uuid)).body | ||
93 | |||
94 | expect(videoDetail.files).to.have.lengthOf(4) | ||
95 | const [originalVideo, transcodedVideo420, transcodedVideo320, transcodedVideo240] = videoDetail.files | ||
96 | assertVideoProperties(originalVideo, 720, 'ogv') | ||
97 | assertVideoProperties(transcodedVideo420, 480, 'mp4') | ||
98 | assertVideoProperties(transcodedVideo320, 360, 'mp4') | ||
99 | assertVideoProperties(transcodedVideo240, 240, 'mp4') | ||
100 | } | ||
101 | }) | ||
102 | |||
103 | after(async function () { | ||
104 | killallServers(servers) | ||
105 | |||
106 | // Keep the logs if the test failed | ||
107 | if (this['ok']) { | ||
108 | await flushTests() | ||
109 | } | ||
110 | }) | ||
111 | }) | ||
diff --git a/server/tests/cli/index.ts b/server/tests/cli/index.ts index f0317aac0..f99eafe03 100644 --- a/server/tests/cli/index.ts +++ b/server/tests/cli/index.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | // Order of the tests we want to execute | 1 | // Order of the tests we want to execute |
2 | import './create-transcoding-job' | 2 | import './create-transcoding-job' |
3 | import './create-import-video-file-job' | ||
3 | import './reset-password' | 4 | import './reset-password' |
4 | import './update-host' | 5 | import './update-host' |
diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts index 0fa36820e..7d8d39a19 100644 --- a/shared/models/server/job.model.ts +++ b/shared/models/server/job.model.ts | |||
@@ -4,6 +4,7 @@ export type JobType = 'activitypub-http-unicast' | | |||
4 | 'activitypub-http-broadcast' | | 4 | 'activitypub-http-broadcast' | |
5 | 'activitypub-http-fetcher' | | 5 | 'activitypub-http-fetcher' | |
6 | 'activitypub-follow' | | 6 | 'activitypub-follow' | |
7 | 'video-file-import' | | ||
7 | 'video-file' | | 8 | 'video-file' | |
8 | 'email' | 9 | 'email' |
9 | 10 | ||