aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/helpers
diff options
context:
space:
mode:
Diffstat (limited to 'server/helpers')
-rw-r--r--server/helpers/activitypub.ts1
-rw-r--r--server/helpers/audit-logger.ts265
-rw-r--r--server/helpers/core-utils.ts2
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts5
-rw-r--r--server/helpers/custom-validators/video-imports.ts49
-rw-r--r--server/helpers/ffmpeg-utils.ts155
-rw-r--r--server/helpers/logger.ts4
-rw-r--r--server/helpers/utils.ts28
-rw-r--r--server/helpers/youtube-dl.ts142
9 files changed, 635 insertions, 16 deletions
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts
index d710f5c97..a9de11fb0 100644
--- a/server/helpers/activitypub.ts
+++ b/server/helpers/activitypub.ts
@@ -24,6 +24,7 @@ function activityPubContextify <T> (data: T) {
24 views: 'http://schema.org/Number', 24 views: 'http://schema.org/Number',
25 stats: 'http://schema.org/Number', 25 stats: 'http://schema.org/Number',
26 size: 'http://schema.org/Number', 26 size: 'http://schema.org/Number',
27 fps: 'http://schema.org/Number',
27 commentsEnabled: 'http://schema.org/Boolean', 28 commentsEnabled: 'http://schema.org/Boolean',
28 waitTranscoding: 'http://schema.org/Boolean', 29 waitTranscoding: 'http://schema.org/Boolean',
29 support: 'http://schema.org/Text' 30 support: 'http://schema.org/Text'
diff --git a/server/helpers/audit-logger.ts b/server/helpers/audit-logger.ts
new file mode 100644
index 000000000..db20df20f
--- /dev/null
+++ b/server/helpers/audit-logger.ts
@@ -0,0 +1,265 @@
1import * as path from 'path'
2import { diff } from 'deep-object-diff'
3import { chain } from 'lodash'
4import * as flatten from 'flat'
5import * as winston from 'winston'
6import { CONFIG } from '../initializers'
7import { jsonLoggerFormat, labelFormatter } from './logger'
8import { VideoDetails, User, VideoChannel, VideoAbuse, VideoImport } from '../../shared'
9import { VideoComment } from '../../shared/models/videos/video-comment.model'
10import { CustomConfig } from '../../shared/models/server/custom-config.model'
11
12enum AUDIT_TYPE {
13 CREATE = 'create',
14 UPDATE = 'update',
15 DELETE = 'delete'
16}
17
18const colors = winston.config.npm.colors
19colors.audit = winston.config.npm.colors.info
20
21winston.addColors(colors)
22
23const auditLogger = winston.createLogger({
24 levels: { audit: 0 },
25 transports: [
26 new winston.transports.File({
27 filename: path.join(CONFIG.STORAGE.LOG_DIR, 'peertube-audit.log'),
28 level: 'audit',
29 maxsize: 5242880,
30 maxFiles: 5,
31 format: winston.format.combine(
32 winston.format.timestamp(),
33 labelFormatter,
34 winston.format.splat(),
35 jsonLoggerFormat
36 )
37 })
38 ],
39 exitOnError: true
40})
41
42function auditLoggerWrapper (domain: string, user: string, action: AUDIT_TYPE, entity: EntityAuditView, oldEntity: EntityAuditView = null) {
43 let entityInfos: object
44 if (action === AUDIT_TYPE.UPDATE && oldEntity) {
45 const oldEntityKeys = oldEntity.toLogKeys()
46 const diffObject = diff(oldEntityKeys, entity.toLogKeys())
47 const diffKeys = Object.entries(diffObject).reduce((newKeys, entry) => {
48 newKeys[`new-${entry[0]}`] = entry[1]
49 return newKeys
50 }, {})
51 entityInfos = { ...oldEntityKeys, ...diffKeys }
52 } else {
53 entityInfos = { ...entity.toLogKeys() }
54 }
55 auditLogger.log('audit', JSON.stringify({
56 user,
57 domain,
58 action,
59 ...entityInfos
60 }))
61}
62
63function auditLoggerFactory (domain: string) {
64 return {
65 create (user: string, entity: EntityAuditView) {
66 auditLoggerWrapper(domain, user, AUDIT_TYPE.CREATE, entity)
67 },
68 update (user: string, entity: EntityAuditView, oldEntity: EntityAuditView) {
69 auditLoggerWrapper(domain, user, AUDIT_TYPE.UPDATE, entity, oldEntity)
70 },
71 delete (user: string, entity: EntityAuditView) {
72 auditLoggerWrapper(domain, user, AUDIT_TYPE.DELETE, entity)
73 }
74 }
75}
76
77abstract class EntityAuditView {
78 constructor (private keysToKeep: Array<string>, private prefix: string, private entityInfos: object) { }
79 toLogKeys (): object {
80 return chain(flatten(this.entityInfos, { delimiter: '-', safe: true }))
81 .pick(this.keysToKeep)
82 .mapKeys((value, key) => `${this.prefix}-${key}`)
83 .value()
84 }
85}
86
87const videoKeysToKeep = [
88 'tags',
89 'uuid',
90 'id',
91 'uuid',
92 'createdAt',
93 'updatedAt',
94 'publishedAt',
95 'category',
96 'licence',
97 'language',
98 'privacy',
99 'description',
100 'duration',
101 'isLocal',
102 'name',
103 'thumbnailPath',
104 'previewPath',
105 'nsfw',
106 'waitTranscoding',
107 'account-id',
108 'account-uuid',
109 'account-name',
110 'channel-id',
111 'channel-uuid',
112 'channel-name',
113 'support',
114 'commentsEnabled'
115]
116class VideoAuditView extends EntityAuditView {
117 constructor (private video: VideoDetails) {
118 super(videoKeysToKeep, 'video', video)
119 }
120}
121
122const videoImportKeysToKeep = [
123 'id',
124 'targetUrl',
125 'video-name'
126]
127class VideoImportAuditView extends EntityAuditView {
128 constructor (private videoImport: VideoImport) {
129 super(videoImportKeysToKeep, 'video-import', videoImport)
130 }
131}
132
133const commentKeysToKeep = [
134 'id',
135 'text',
136 'threadId',
137 'inReplyToCommentId',
138 'videoId',
139 'createdAt',
140 'updatedAt',
141 'totalReplies',
142 'account-id',
143 'account-uuid',
144 'account-name'
145]
146class CommentAuditView extends EntityAuditView {
147 constructor (private comment: VideoComment) {
148 super(commentKeysToKeep, 'comment', comment)
149 }
150}
151
152const userKeysToKeep = [
153 'id',
154 'username',
155 'email',
156 'nsfwPolicy',
157 'autoPlayVideo',
158 'role',
159 'videoQuota',
160 'createdAt',
161 'account-id',
162 'account-uuid',
163 'account-name',
164 'account-followingCount',
165 'account-followersCount',
166 'account-createdAt',
167 'account-updatedAt',
168 'account-avatar-path',
169 'account-avatar-createdAt',
170 'account-avatar-updatedAt',
171 'account-displayName',
172 'account-description',
173 'videoChannels'
174]
175class UserAuditView extends EntityAuditView {
176 constructor (private user: User) {
177 super(userKeysToKeep, 'user', user)
178 }
179}
180
181const channelKeysToKeep = [
182 'id',
183 'uuid',
184 'name',
185 'followingCount',
186 'followersCount',
187 'createdAt',
188 'updatedAt',
189 'avatar-path',
190 'avatar-createdAt',
191 'avatar-updatedAt',
192 'displayName',
193 'description',
194 'support',
195 'isLocal',
196 'ownerAccount-id',
197 'ownerAccount-uuid',
198 'ownerAccount-name',
199 'ownerAccount-displayedName'
200]
201class VideoChannelAuditView extends EntityAuditView {
202 constructor (private channel: VideoChannel) {
203 super(channelKeysToKeep, 'channel', channel)
204 }
205}
206
207const videoAbuseKeysToKeep = [
208 'id',
209 'reason',
210 'reporterAccount',
211 'video-id',
212 'video-name',
213 'video-uuid',
214 'createdAt'
215]
216class VideoAbuseAuditView extends EntityAuditView {
217 constructor (private videoAbuse: VideoAbuse) {
218 super(videoAbuseKeysToKeep, 'abuse', videoAbuse)
219 }
220}
221
222const customConfigKeysToKeep = [
223 'instance-name',
224 'instance-shortDescription',
225 'instance-description',
226 'instance-terms',
227 'instance-defaultClientRoute',
228 'instance-defaultNSFWPolicy',
229 'instance-customizations-javascript',
230 'instance-customizations-css',
231 'services-twitter-username',
232 'services-twitter-whitelisted',
233 'cache-previews-size',
234 'cache-captions-size',
235 'signup-enabled',
236 'signup-limit',
237 'admin-email',
238 'user-videoQuota',
239 'transcoding-enabled',
240 'transcoding-threads',
241 'transcoding-resolutions'
242]
243class CustomConfigAuditView extends EntityAuditView {
244 constructor (customConfig: CustomConfig) {
245 const infos: any = customConfig
246 const resolutionsDict = infos.transcoding.resolutions
247 const resolutionsArray = []
248 Object.entries(resolutionsDict).forEach(([resolution, isEnabled]) => {
249 if (isEnabled) resolutionsArray.push(resolution)
250 })
251 Object.assign({}, infos, { transcoding: { resolutions: resolutionsArray } })
252 super(customConfigKeysToKeep, 'config', infos)
253 }
254}
255
256export {
257 auditLoggerFactory,
258 VideoImportAuditView,
259 VideoChannelAuditView,
260 CommentAuditView,
261 UserAuditView,
262 VideoAuditView,
263 VideoAbuseAuditView,
264 CustomConfigAuditView
265}
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index 2951aef1e..884206aad 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -58,7 +58,7 @@ function escapeHTML (stringParam) {
58 '<': '&lt;', 58 '<': '&lt;',
59 '>': '&gt;', 59 '>': '&gt;',
60 '"': '&quot;', 60 '"': '&quot;',
61 "'": '&#39;', 61 '\'': '&#39;',
62 '/': '&#x2F;', 62 '/': '&#x2F;',
63 '`': '&#x60;', 63 '`': '&#x60;',
64 '=': '&#x3D;' 64 '=': '&#x3D;'
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index d97bbd2a9..b8075f3c7 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -45,7 +45,7 @@ function isActivityPubVideoDurationValid (value: string) {
45} 45}
46 46
47function sanitizeAndCheckVideoTorrentObject (video: any) { 47function sanitizeAndCheckVideoTorrentObject (video: any) {
48 if (video.type !== 'Video') return false 48 if (!video || video.type !== 'Video') return false
49 49
50 if (!setValidRemoteTags(video)) return false 50 if (!setValidRemoteTags(video)) return false
51 if (!setValidRemoteVideoUrls(video)) return false 51 if (!setValidRemoteVideoUrls(video)) return false
@@ -153,7 +153,8 @@ function isRemoteVideoUrlValid (url: any) {
153 ACTIVITY_PUB.URL_MIME_TYPES.VIDEO.indexOf(url.mimeType) !== -1 && 153 ACTIVITY_PUB.URL_MIME_TYPES.VIDEO.indexOf(url.mimeType) !== -1 &&
154 isActivityPubUrlValid(url.href) && 154 isActivityPubUrlValid(url.href) &&
155 validator.isInt(url.width + '', { min: 0 }) && 155 validator.isInt(url.width + '', { min: 0 }) &&
156 validator.isInt(url.size + '', { min: 0 }) 156 validator.isInt(url.size + '', { min: 0 }) &&
157 (!url.fps || validator.isInt(url.fps + '', { min: 0 }))
157 ) || 158 ) ||
158 ( 159 (
159 ACTIVITY_PUB.URL_MIME_TYPES.TORRENT.indexOf(url.mimeType) !== -1 && 160 ACTIVITY_PUB.URL_MIME_TYPES.TORRENT.indexOf(url.mimeType) !== -1 &&
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
new file mode 100644
index 000000000..d8b9bfaff
--- /dev/null
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -0,0 +1,49 @@
1import 'express-validator'
2import 'multer'
3import * as validator from 'validator'
4import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers'
5import { exists } from './misc'
6import * as express from 'express'
7import { VideoChannelModel } from '../../models/video/video-channel'
8import { VideoImportModel } from '../../models/video/video-import'
9
10function isVideoImportTargetUrlValid (url: string) {
11 const isURLOptions = {
12 require_host: true,
13 require_tld: true,
14 require_protocol: true,
15 require_valid_protocol: true,
16 protocols: [ 'http', 'https' ]
17 }
18
19 return exists(url) &&
20 validator.isURL('' + url, isURLOptions) &&
21 validator.isLength('' + url, CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL)
22}
23
24function isVideoImportStateValid (value: any) {
25 return exists(value) && VIDEO_IMPORT_STATES[ value ] !== undefined
26}
27
28async function isVideoImportExist (id: number, res: express.Response) {
29 const videoImport = await VideoImportModel.loadAndPopulateVideo(id)
30
31 if (!videoImport) {
32 res.status(404)
33 .json({ error: 'Video import not found' })
34 .end()
35
36 return false
37 }
38
39 res.locals.videoImport = videoImport
40 return true
41}
42
43// ---------------------------------------------------------------------------
44
45export {
46 isVideoImportStateValid,
47 isVideoImportTargetUrlValid,
48 isVideoImportExist
49}
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index f0623c88b..ced56b82d 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -1,10 +1,11 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { join } from 'path' 2import { join } from 'path'
3import { VideoResolution } from '../../shared/models/videos' 3import { VideoResolution } from '../../shared/models/videos'
4import { CONFIG, VIDEO_TRANSCODING_FPS } from '../initializers' 4import { CONFIG, VIDEO_TRANSCODING_FPS, FFMPEG_NICE } from '../initializers'
5import { unlinkPromise } from './core-utils' 5import { unlinkPromise } from './core-utils'
6import { processImage } from './image-utils' 6import { processImage } from './image-utils'
7import { logger } from './logger' 7import { logger } from './logger'
8import { checkFFmpegEncoders } from '../initializers/checker'
8 9
9async function getVideoFileResolution (path: string) { 10async function getVideoFileResolution (path: string) {
10 const videoStream = await getVideoFileStream(path) 11 const videoStream = await getVideoFileStream(path)
@@ -55,7 +56,7 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
55 56
56 try { 57 try {
57 await new Promise<string>((res, rej) => { 58 await new Promise<string>((res, rej) => {
58 ffmpeg(fromPath) 59 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
59 .on('error', rej) 60 .on('error', rej)
60 .on('end', () => res(imageName)) 61 .on('end', () => res(imageName))
61 .thumbnail(options) 62 .thumbnail(options)
@@ -83,14 +84,14 @@ type TranscodeOptions = {
83 84
84function transcode (options: TranscodeOptions) { 85function transcode (options: TranscodeOptions) {
85 return new Promise<void>(async (res, rej) => { 86 return new Promise<void>(async (res, rej) => {
86 let command = ffmpeg(options.inputPath) 87 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
87 .output(options.outputPath) 88 .output(options.outputPath)
88 .videoCodec('libx264') 89 .preset(standard)
89 .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) 90
90 .outputOption('-movflags faststart') 91 if (CONFIG.TRANSCODING.THREADS > 0) {
91 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it 92 // if we don't set any threads ffmpeg will chose automatically
92 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 93 command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
93 // .outputOption('-crf 18') 94 }
94 95
95 let fps = await getVideoFileFPS(options.inputPath) 96 let fps = await getVideoFileFPS(options.inputPath)
96 if (options.resolution !== undefined) { 97 if (options.resolution !== undefined) {
@@ -132,7 +133,8 @@ export {
132 getDurationFromVideoFile, 133 getDurationFromVideoFile,
133 generateImageFromVideoFile, 134 generateImageFromVideoFile,
134 transcode, 135 transcode,
135 getVideoFileFPS 136 getVideoFileFPS,
137 audio
136} 138}
137 139
138// --------------------------------------------------------------------------- 140// ---------------------------------------------------------------------------
@@ -149,3 +151,136 @@ function getVideoFileStream (path: string) {
149 }) 151 })
150 }) 152 })
151} 153}
154
155/**
156 * A slightly customised version of the 'veryfast' x264 preset
157 *
158 * The veryfast preset is right in the sweet spot of performance
159 * and quality. Superfast and ultrafast will give you better
160 * performance, but then quality is noticeably worse.
161 */
162function veryfast (_ffmpeg) {
163 _ffmpeg
164 .preset(standard)
165 .outputOption('-preset:v veryfast')
166 .outputOption(['--aq-mode=2', '--aq-strength=1.3'])
167 /*
168 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
169 Our target situation is closer to a livestream than a stream,
170 since we want to reduce as much a possible the encoding burden,
171 altough not to the point of a livestream where there is a hard
172 constraint on the frames per second to be encoded.
173
174 why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'?
175 Make up for most of the loss of grain and macroblocking
176 with less computing power.
177 */
178}
179
180/**
181 * A preset optimised for a stillimage audio video
182 */
183function audio (_ffmpeg) {
184 _ffmpeg
185 .preset(veryfast)
186 .outputOption('-tune stillimage')
187}
188
189/**
190 * A toolbox to play with audio
191 */
192namespace audio {
193 export const get = (_ffmpeg, pos: number | string = 0) => {
194 // without position, ffprobe considers the last input only
195 // we make it consider the first input only
196 // if you pass a file path to pos, then ffprobe acts on that file directly
197 return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => {
198 _ffmpeg.ffprobe(pos, (err,data) => {
199 if (err) return rej(err)
200
201 if ('streams' in data) {
202 const audioStream = data['streams'].find(stream => stream['codec_type'] === 'audio')
203 if (audioStream) {
204 return res({
205 absolutePath: data.format.filename,
206 audioStream
207 })
208 }
209 }
210 return res({ absolutePath: data.format.filename })
211 })
212 })
213 }
214
215 export namespace bitrate {
216 export const baseKbitrate = 384
217
218 const toBits = (kbits: number): number => { return kbits * 8000 }
219
220 export const aac = (bitrate: number): number => {
221 switch (true) {
222 case bitrate > toBits(baseKbitrate):
223 return baseKbitrate
224 default:
225 return -1 // we interpret it as a signal to copy the audio stream as is
226 }
227 }
228
229 export const mp3 = (bitrate: number): number => {
230 /*
231 a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
232 That's why, when using aac, we can go to lower kbit/sec. The equivalences
233 made here are not made to be accurate, especially with good mp3 encoders.
234 */
235 switch (true) {
236 case bitrate <= toBits(192):
237 return 128
238 case bitrate <= toBits(384):
239 return 256
240 default:
241 return baseKbitrate
242 }
243 }
244 }
245}
246
247/**
248 * Standard profile, with variable bitrate audio and faststart.
249 *
250 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
251 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
252 */
253async function standard (_ffmpeg) {
254 let _bitrate = audio.bitrate.baseKbitrate
255 let localFfmpeg = _ffmpeg
256 .format('mp4')
257 .videoCodec('libx264')
258 .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution
259 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it
260 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
261 .outputOption('-map_metadata -1') // strip all metadata
262 .outputOption('-movflags faststart')
263 const _audio = await audio.get(localFfmpeg)
264
265 if (!_audio.audioStream) {
266 return localFfmpeg.noAudio()
267 }
268
269 // we try to reduce the ceiling bitrate by making rough correspondances of bitrates
270 // of course this is far from perfect, but it might save some space in the end
271 if (audio.bitrate[_audio.audioStream['codec_name']]) {
272 _bitrate = audio.bitrate[_audio.audioStream['codec_name']](_audio.audioStream['bit_rate'])
273 if (_bitrate === -1) {
274 return localFfmpeg.audioCodec('copy')
275 }
276 }
277
278 // we favor VBR, if a good AAC encoder is available
279 if ((await checkFFmpegEncoders()).get('libfdk_aac')) {
280 return localFfmpeg
281 .audioCodec('libfdk_aac')
282 .audioQuality(5)
283 }
284
285 return localFfmpeg.audioBitrate(_bitrate)
286}
diff --git a/server/helpers/logger.ts b/server/helpers/logger.ts
index 6d369a8fb..480c5b49e 100644
--- a/server/helpers/logger.ts
+++ b/server/helpers/logger.ts
@@ -22,7 +22,7 @@ function loggerReplacer (key: string, value: any) {
22} 22}
23 23
24const consoleLoggerFormat = winston.format.printf(info => { 24const consoleLoggerFormat = winston.format.printf(info => {
25 let additionalInfos = JSON.stringify(info.meta, loggerReplacer, 2) 25 let additionalInfos = JSON.stringify(info.meta || info.err, loggerReplacer, 2)
26 if (additionalInfos === undefined || additionalInfos === '{}') additionalInfos = '' 26 if (additionalInfos === undefined || additionalInfos === '{}') additionalInfos = ''
27 else additionalInfos = ' ' + additionalInfos 27 else additionalInfos = ' ' + additionalInfos
28 28
@@ -96,13 +96,13 @@ const bunyanLogger = {
96 error: bunyanLogFactory('error'), 96 error: bunyanLogFactory('error'),
97 fatal: bunyanLogFactory('error') 97 fatal: bunyanLogFactory('error')
98} 98}
99
100// --------------------------------------------------------------------------- 99// ---------------------------------------------------------------------------
101 100
102export { 101export {
103 timestampFormatter, 102 timestampFormatter,
104 labelFormatter, 103 labelFormatter,
105 consoleLoggerFormat, 104 consoleLoggerFormat,
105 jsonLoggerFormat,
106 logger, 106 logger,
107 bunyanLogger 107 bunyanLogger
108} 108}
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts
index cfb427570..7abcec5d7 100644
--- a/server/helpers/utils.ts
+++ b/server/helpers/utils.ts
@@ -6,11 +6,35 @@ import { CONFIG } from '../initializers'
6import { UserModel } from '../models/account/user' 6import { UserModel } from '../models/account/user'
7import { ActorModel } from '../models/activitypub/actor' 7import { ActorModel } from '../models/activitypub/actor'
8import { ApplicationModel } from '../models/application/application' 8import { ApplicationModel } from '../models/application/application'
9import { pseudoRandomBytesPromise } from './core-utils' 9import { pseudoRandomBytesPromise, unlinkPromise } from './core-utils'
10import { logger } from './logger' 10import { logger } from './logger'
11import { isArray } from './custom-validators/misc'
11 12
12const isCidr = require('is-cidr') 13const isCidr = require('is-cidr')
13 14
15function cleanUpReqFiles (req: { files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[] }) {
16 const files = req.files
17
18 if (!files) return
19
20 if (isArray(files)) {
21 (files as Express.Multer.File[]).forEach(f => deleteFileAsync(f.path))
22 return
23 }
24
25 for (const key of Object.keys(files)) {
26 const file = files[key]
27
28 if (isArray(file)) file.forEach(f => deleteFileAsync(f.path))
29 else deleteFileAsync(file.path)
30 }
31}
32
33function deleteFileAsync (path: string) {
34 unlinkPromise(path)
35 .catch(err => logger.error('Cannot delete the file %s asynchronously.', path, { err }))
36}
37
14async function generateRandomString (size: number) { 38async function generateRandomString (size: number) {
15 const raw = await pseudoRandomBytesPromise(size) 39 const raw = await pseudoRandomBytesPromise(size)
16 40
@@ -162,6 +186,8 @@ type SortType = { sortModel: any, sortValue: string }
162// --------------------------------------------------------------------------- 186// ---------------------------------------------------------------------------
163 187
164export { 188export {
189 cleanUpReqFiles,
190 deleteFileAsync,
165 generateRandomString, 191 generateRandomString,
166 getFormattedObjects, 192 getFormattedObjects,
167 isSignupAllowed, 193 isSignupAllowed,
diff --git a/server/helpers/youtube-dl.ts b/server/helpers/youtube-dl.ts
new file mode 100644
index 000000000..c59ab9de0
--- /dev/null
+++ b/server/helpers/youtube-dl.ts
@@ -0,0 +1,142 @@
1import * as youtubeDL from 'youtube-dl'
2import { truncate } from 'lodash'
3import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers'
4import { join } from 'path'
5import * as crypto from 'crypto'
6import { logger } from './logger'
7
8export type YoutubeDLInfo = {
9 name: string
10 description: string
11 category: number
12 licence: number
13 nsfw: boolean
14 tags: string[]
15 thumbnailUrl: string
16}
17
18function getYoutubeDLInfo (url: string): Promise<YoutubeDLInfo> {
19 return new Promise<YoutubeDLInfo>((res, rej) => {
20 const options = [ '-j', '--flat-playlist' ]
21
22 youtubeDL.getInfo(url, options, (err, info) => {
23 if (err) return rej(err)
24
25 const obj = normalizeObject(info)
26
27 return res(buildVideoInfo(obj))
28 })
29 })
30}
31
32function downloadYoutubeDLVideo (url: string) {
33 const hash = crypto.createHash('sha256').update(url).digest('hex')
34 const path = join(CONFIG.STORAGE.VIDEOS_DIR, hash + '-import.mp4')
35
36 logger.info('Importing video %s', url)
37
38 const options = [ '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', '-o', path ]
39
40 return new Promise<string>((res, rej) => {
41 youtubeDL.exec(url, options, async (err, output) => {
42 if (err) return rej(err)
43
44 return res(path)
45 })
46 })
47}
48
49// ---------------------------------------------------------------------------
50
51export {
52 downloadYoutubeDLVideo,
53 getYoutubeDLInfo
54}
55
56// ---------------------------------------------------------------------------
57
58function normalizeObject (obj: any) {
59 const newObj: any = {}
60
61 for (const key of Object.keys(obj)) {
62 // Deprecated key
63 if (key === 'resolution') continue
64
65 const value = obj[key]
66
67 if (typeof value === 'string') {
68 newObj[key] = value.normalize()
69 } else {
70 newObj[key] = value
71 }
72 }
73
74 return newObj
75}
76
77function buildVideoInfo (obj: any) {
78 return {
79 name: titleTruncation(obj.title),
80 description: descriptionTruncation(obj.description),
81 category: getCategory(obj.categories),
82 licence: getLicence(obj.license),
83 nsfw: isNSFW(obj),
84 tags: getTags(obj.tags),
85 thumbnailUrl: obj.thumbnail || undefined
86 }
87}
88
89function titleTruncation (title: string) {
90 return truncate(title, {
91 'length': CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
92 'separator': /,? +/,
93 'omission': ' […]'
94 })
95}
96
97function descriptionTruncation (description: string) {
98 if (!description || description.length < CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.min) return undefined
99
100 return truncate(description, {
101 'length': CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max,
102 'separator': /,? +/,
103 'omission': ' […]'
104 })
105}
106
107function isNSFW (info: any) {
108 return info.age_limit && info.age_limit >= 16
109}
110
111function getTags (tags: any) {
112 if (Array.isArray(tags) === false) return []
113
114 return tags
115 .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
116 .map(t => t.normalize())
117 .slice(0, 5)
118}
119
120function getLicence (licence: string) {
121 if (!licence) return undefined
122
123 if (licence.indexOf('Creative Commons Attribution') !== -1) return 1
124
125 return undefined
126}
127
128function getCategory (categories: string[]) {
129 if (!categories) return undefined
130
131 const categoryString = categories[0]
132 if (!categoryString || typeof categoryString !== 'string') return undefined
133
134 if (categoryString === 'News & Politics') return 11
135
136 for (const key of Object.keys(VIDEO_CATEGORIES)) {
137 const category = VIDEO_CATEGORIES[key]
138 if (categoryString.toLowerCase() === category.toLowerCase()) return parseInt(key, 10)
139 }
140
141 return undefined
142}