]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video.ts
Client: handle multiple file resolutions
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
CommitLineData
4d4e5cd4 1import * as safeBuffer from 'safe-buffer'
65fcc311 2const Buffer = safeBuffer.Buffer
4d4e5cd4 3import * as ffmpeg from 'fluent-ffmpeg'
4d4e5cd4 4import * as magnetUtil from 'magnet-uri'
93e1258c 5import { map } from 'lodash'
4d4e5cd4 6import * as parseTorrent from 'parse-torrent'
65fcc311 7import { join } from 'path'
e02643f3 8import * as Sequelize from 'sequelize'
6fcd19ba 9import * as Promise from 'bluebird'
65fcc311 10
6fcd19ba 11import { TagInstance } from './tag-interface'
65fcc311
C
12import {
13 logger,
14 isVideoNameValid,
15 isVideoCategoryValid,
16 isVideoLicenceValid,
17 isVideoLanguageValid,
18 isVideoNSFWValid,
19 isVideoDescriptionValid,
6fcd19ba
C
20 isVideoDurationValid,
21 readFileBufferPromise,
22 unlinkPromise,
23 renamePromise,
24 writeFilePromise,
40298b02
C
25 createTorrentPromise,
26 statPromise
74889a71 27} from '../../helpers'
65fcc311 28import {
65fcc311
C
29 CONFIG,
30 REMOTE_SCHEME,
31 STATIC_PATHS,
32 VIDEO_CATEGORIES,
33 VIDEO_LICENCES,
34 VIDEO_LANGUAGES,
93e1258c
C
35 THUMBNAILS_SIZE,
36 VIDEO_FILE_RESOLUTIONS
74889a71 37} from '../../initializers'
93e1258c 38import { removeVideoToFriends } from '../../lib'
40298b02
C
39import { VideoResolution } from '../../../shared'
40import { VideoFileInstance, VideoFileModel } from './video-file-interface'
aaf61f38 41
74889a71 42import { addMethodsToModel, getSort } from '../utils'
e02643f3 43import {
e02643f3
C
44 VideoInstance,
45 VideoAttributes,
46
47 VideoMethods
48} from './video-interface'
49
50let Video: Sequelize.Model<VideoInstance, VideoAttributes>
40298b02 51let getOriginalFile: VideoMethods.GetOriginalFile
e02643f3
C
52let generateMagnetUri: VideoMethods.GenerateMagnetUri
53let getVideoFilename: VideoMethods.GetVideoFilename
54let getThumbnailName: VideoMethods.GetThumbnailName
55let getPreviewName: VideoMethods.GetPreviewName
93e1258c 56let getTorrentFileName: VideoMethods.GetTorrentFileName
e02643f3 57let isOwned: VideoMethods.IsOwned
0aef76c4 58let toFormattedJSON: VideoMethods.ToFormattedJSON
e02643f3
C
59let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
60let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
40298b02
C
61let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
62let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
93e1258c
C
63let createPreview: VideoMethods.CreatePreview
64let createThumbnail: VideoMethods.CreateThumbnail
65let getVideoFilePath: VideoMethods.GetVideoFilePath
66let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
40298b02 67let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
e02643f3
C
68
69let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
70let getDurationFromFile: VideoMethods.GetDurationFromFile
71let list: VideoMethods.List
72let listForApi: VideoMethods.ListForApi
0a6658fd 73let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
e02643f3
C
74let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
75let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
76let load: VideoMethods.Load
0a6658fd 77let loadByUUID: VideoMethods.LoadByUUID
e02643f3
C
78let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
79let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
0a6658fd 80let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
e02643f3 81let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
93e1258c
C
82let removeThumbnail: VideoMethods.RemoveThumbnail
83let removePreview: VideoMethods.RemovePreview
84let removeFile: VideoMethods.RemoveFile
85let removeTorrent: VideoMethods.RemoveTorrent
e02643f3 86
127944aa
C
87export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
88 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
feb4bdfd 89 {
0a6658fd 90 uuid: {
feb4bdfd
C
91 type: DataTypes.UUID,
92 defaultValue: DataTypes.UUIDV4,
0a6658fd 93 allowNull: false,
67bf9b96
C
94 validate: {
95 isUUID: 4
96 }
aaf61f38 97 },
feb4bdfd 98 name: {
67bf9b96
C
99 type: DataTypes.STRING,
100 allowNull: false,
101 validate: {
075f16ca 102 nameValid: value => {
65fcc311 103 const res = isVideoNameValid(value)
67bf9b96
C
104 if (res === false) throw new Error('Video name is not valid.')
105 }
106 }
6a94a109 107 },
6e07c3de
C
108 category: {
109 type: DataTypes.INTEGER,
110 allowNull: false,
111 validate: {
075f16ca 112 categoryValid: value => {
65fcc311 113 const res = isVideoCategoryValid(value)
6e07c3de
C
114 if (res === false) throw new Error('Video category is not valid.')
115 }
116 }
117 },
6f0c39e2
C
118 licence: {
119 type: DataTypes.INTEGER,
120 allowNull: false,
3092476e 121 defaultValue: null,
6f0c39e2 122 validate: {
075f16ca 123 licenceValid: value => {
65fcc311 124 const res = isVideoLicenceValid(value)
6f0c39e2
C
125 if (res === false) throw new Error('Video licence is not valid.')
126 }
127 }
128 },
3092476e
C
129 language: {
130 type: DataTypes.INTEGER,
131 allowNull: true,
132 validate: {
075f16ca 133 languageValid: value => {
65fcc311 134 const res = isVideoLanguageValid(value)
3092476e
C
135 if (res === false) throw new Error('Video language is not valid.')
136 }
137 }
138 },
31b59b47
C
139 nsfw: {
140 type: DataTypes.BOOLEAN,
141 allowNull: false,
142 validate: {
075f16ca 143 nsfwValid: value => {
65fcc311 144 const res = isVideoNSFWValid(value)
31b59b47
C
145 if (res === false) throw new Error('Video nsfw attribute is not valid.')
146 }
147 }
148 },
feb4bdfd 149 description: {
67bf9b96
C
150 type: DataTypes.STRING,
151 allowNull: false,
152 validate: {
075f16ca 153 descriptionValid: value => {
65fcc311 154 const res = isVideoDescriptionValid(value)
67bf9b96
C
155 if (res === false) throw new Error('Video description is not valid.')
156 }
157 }
feb4bdfd 158 },
feb4bdfd 159 duration: {
67bf9b96
C
160 type: DataTypes.INTEGER,
161 allowNull: false,
162 validate: {
075f16ca 163 durationValid: value => {
65fcc311 164 const res = isVideoDurationValid(value)
67bf9b96
C
165 if (res === false) throw new Error('Video duration is not valid.')
166 }
167 }
9e167724
C
168 },
169 views: {
170 type: DataTypes.INTEGER,
171 allowNull: false,
172 defaultValue: 0,
173 validate: {
174 min: 0,
175 isInt: true
176 }
d38b8281
C
177 },
178 likes: {
179 type: DataTypes.INTEGER,
180 allowNull: false,
181 defaultValue: 0,
182 validate: {
183 min: 0,
184 isInt: true
185 }
186 },
187 dislikes: {
188 type: DataTypes.INTEGER,
189 allowNull: false,
190 defaultValue: 0,
191 validate: {
192 min: 0,
193 isInt: true
194 }
0a6658fd
C
195 },
196 remote: {
197 type: DataTypes.BOOLEAN,
198 allowNull: false,
199 defaultValue: false
aaf61f38 200 }
feb4bdfd
C
201 },
202 {
319d072e
C
203 indexes: [
204 {
205 fields: [ 'authorId' ]
206 },
319d072e
C
207 {
208 fields: [ 'name' ]
209 },
210 {
211 fields: [ 'createdAt' ]
212 },
213 {
214 fields: [ 'duration' ]
215 },
9e167724
C
216 {
217 fields: [ 'views' ]
d38b8281
C
218 },
219 {
220 fields: [ 'likes' ]
0a6658fd
C
221 },
222 {
223 fields: [ 'uuid' ]
319d072e
C
224 }
225 ],
feb4bdfd 226 hooks: {
feb4bdfd
C
227 afterDestroy
228 }
229 }
230 )
aaf61f38 231
e02643f3
C
232 const classMethods = [
233 associate,
234
235 generateThumbnailFromData,
236 getDurationFromFile,
237 list,
238 listForApi,
239 listOwnedAndPopulateAuthorAndTags,
240 listOwnedByAuthor,
241 load,
e02643f3
C
242 loadAndPopulateAuthor,
243 loadAndPopulateAuthorAndPodAndTags,
93e1258c
C
244 loadByHostAndUUID,
245 loadByUUID,
0a6658fd 246 loadByUUIDAndPopulateAuthorAndPodAndTags,
93e1258c 247 searchAndPopulateAuthorAndPodAndTags
e02643f3
C
248 ]
249 const instanceMethods = [
93e1258c
C
250 createPreview,
251 createThumbnail,
252 createTorrentAndSetInfoHash,
e02643f3 253 generateMagnetUri,
e02643f3 254 getPreviewName,
93e1258c
C
255 getThumbnailName,
256 getTorrentFileName,
257 getVideoFilename,
258 getVideoFilePath,
40298b02 259 getOriginalFile,
e02643f3 260 isOwned,
93e1258c
C
261 removeFile,
262 removePreview,
263 removeThumbnail,
264 removeTorrent,
e02643f3 265 toAddRemoteJSON,
0aef76c4 266 toFormattedJSON,
e02643f3 267 toUpdateRemoteJSON,
40298b02
C
268 optimizeOriginalVideofile,
269 transcodeOriginalVideofile,
270 getOriginalFileHeight
e02643f3
C
271 ]
272 addMethodsToModel(Video, classMethods, instanceMethods)
273
feb4bdfd
C
274 return Video
275}
aaf61f38 276
aaf61f38
C
277// ------------------------------ METHODS ------------------------------
278
feb4bdfd 279function associate (models) {
e02643f3 280 Video.belongsTo(models.Author, {
feb4bdfd
C
281 foreignKey: {
282 name: 'authorId',
283 allowNull: false
284 },
285 onDelete: 'cascade'
286 })
7920c273 287
e02643f3 288 Video.belongsToMany(models.Tag, {
7920c273
C
289 foreignKey: 'videoId',
290 through: models.VideoTag,
291 onDelete: 'cascade'
292 })
55fa55a9 293
e02643f3 294 Video.hasMany(models.VideoAbuse, {
55fa55a9
C
295 foreignKey: {
296 name: 'videoId',
297 allowNull: false
298 },
299 onDelete: 'cascade'
300 })
93e1258c
C
301
302 Video.hasMany(models.VideoFile, {
303 foreignKey: {
304 name: 'videoId',
305 allowNull: false
306 },
307 onDelete: 'cascade'
308 })
feb4bdfd
C
309}
310
91f6f169 311function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
93e1258c 312 const tasks = []
f285faa0 313
93e1258c
C
314 tasks.push(
315 video.removeThumbnail()
316 )
f285faa0 317
93e1258c
C
318 if (video.isOwned()) {
319 const removeVideoToFriendsParams = {
320 uuid: video.uuid
321 }
f285faa0 322
93e1258c
C
323 tasks.push(
324 video.removePreview(),
91f6f169 325 removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
93e1258c
C
326 )
327
91f6f169 328 // Remove physical files and torrents
93e1258c
C
329 video.VideoFiles.forEach(file => {
330 video.removeFile(file),
331 video.removeTorrent(file)
332 })
f285faa0
C
333 }
334
93e1258c 335 return Promise.all(tasks)
558d7c23
C
336}
337
40298b02
C
338getOriginalFile = function (this: VideoInstance) {
339 if (Array.isArray(this.VideoFiles) === false) return undefined
340
341 return this.VideoFiles.find(file => file.resolution === VideoResolution.ORIGINAL)
342}
343
93e1258c 344getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
40298b02 345 return this.uuid + '-' + VIDEO_FILE_RESOLUTIONS[videoFile.resolution] + videoFile.extname
f285faa0
C
346}
347
70c065d6 348getThumbnailName = function (this: VideoInstance) {
f285faa0 349 // We always have a copy of the thumbnail
0a6658fd
C
350 const extension = '.jpg'
351 return this.uuid + extension
558d7c23
C
352}
353
70c065d6 354getPreviewName = function (this: VideoInstance) {
f285faa0 355 const extension = '.jpg'
0a6658fd 356 return this.uuid + extension
f285faa0
C
357}
358
93e1258c 359getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
f285faa0 360 const extension = '.torrent'
40298b02 361 return this.uuid + '-' + VIDEO_FILE_RESOLUTIONS[videoFile.resolution] + extension
558d7c23
C
362}
363
70c065d6 364isOwned = function (this: VideoInstance) {
0a6658fd 365 return this.remote === false
aaf61f38
C
366}
367
93e1258c
C
368createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
369 return generateImage(this, this.getVideoFilePath(videoFile), CONFIG.STORAGE.PREVIEWS_DIR, this.getPreviewName(), null)
370}
371
372createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
373 return generateImage(this, this.getVideoFilePath(videoFile), CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName(), THUMBNAILS_SIZE)
374}
375
376getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
377 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
378}
379
380createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
381 const options = {
382 announceList: [
383 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
384 ],
385 urlList: [
386 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
387 ]
388 }
389
390 return createTorrentPromise(this.getVideoFilePath(videoFile), options)
391 .then(torrent => {
392 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
fdbda9e3
C
393 logger.info('Creating torrent %s.', filePath)
394
93e1258c
C
395 return writeFilePromise(filePath, torrent).then(() => torrent)
396 })
397 .then(torrent => {
398 const parsedTorrent = parseTorrent(torrent)
399
400 videoFile.infoHash = parsedTorrent.infoHash
401 })
402}
403
404generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
405 let baseUrlHttp
406 let baseUrlWs
407
408 if (this.isOwned()) {
409 baseUrlHttp = CONFIG.WEBSERVER.URL
410 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
411 } else {
412 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
413 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
414 }
415
416 const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
417 const announce = [ baseUrlWs + '/tracker/socket' ]
418 const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
419
420 const magnetHash = {
421 xs,
422 announce,
423 urlList,
424 infoHash: videoFile.infoHash,
425 name: this.name
426 }
427
428 return magnetUtil.encode(magnetHash)
429}
430
0aef76c4 431toFormattedJSON = function (this: VideoInstance) {
feb4bdfd
C
432 let podHost
433
434 if (this.Author.Pod) {
435 podHost = this.Author.Pod.host
436 } else {
437 // It means it's our video
65fcc311 438 podHost = CONFIG.WEBSERVER.HOST
feb4bdfd
C
439 }
440
6e07c3de 441 // Maybe our pod is not up to date and there are new categories since our version
65fcc311 442 let categoryLabel = VIDEO_CATEGORIES[this.category]
6e07c3de
C
443 if (!categoryLabel) categoryLabel = 'Misc'
444
6f0c39e2 445 // Maybe our pod is not up to date and there are new licences since our version
65fcc311 446 let licenceLabel = VIDEO_LICENCES[this.licence]
6f0c39e2
C
447 if (!licenceLabel) licenceLabel = 'Unknown'
448
3092476e 449 // Language is an optional attribute
65fcc311 450 let languageLabel = VIDEO_LANGUAGES[this.language]
3092476e
C
451 if (!languageLabel) languageLabel = 'Unknown'
452
aaf61f38 453 const json = {
feb4bdfd 454 id: this.id,
0a6658fd 455 uuid: this.uuid,
aaf61f38 456 name: this.name,
6e07c3de
C
457 category: this.category,
458 categoryLabel,
6f0c39e2
C
459 licence: this.licence,
460 licenceLabel,
3092476e
C
461 language: this.language,
462 languageLabel,
31b59b47 463 nsfw: this.nsfw,
aaf61f38 464 description: this.description,
feb4bdfd 465 podHost,
aaf61f38 466 isLocal: this.isOwned(),
feb4bdfd 467 author: this.Author.name,
aaf61f38 468 duration: this.duration,
9e167724 469 views: this.views,
d38b8281
C
470 likes: this.likes,
471 dislikes: this.dislikes,
6fcd19ba 472 tags: map<TagInstance, string>(this.Tags, 'name'),
65fcc311 473 thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
f981dae8 474 previewPath: join(STATIC_PATHS.PREVIEWS, this.getPreviewName()),
79066fdf 475 createdAt: this.createdAt,
93e1258c
C
476 updatedAt: this.updatedAt,
477 files: []
aaf61f38
C
478 }
479
aa8b6df4
C
480 // Format and sort video files
481 json.files = this.VideoFiles
482 .map(videoFile => {
483 let resolutionLabel = VIDEO_FILE_RESOLUTIONS[videoFile.resolution]
484 if (!resolutionLabel) resolutionLabel = 'Unknown'
485
486 const videoFileJson = {
487 resolution: videoFile.resolution,
488 resolutionLabel,
489 magnetUri: this.generateMagnetUri(videoFile),
490 size: videoFile.size
491 }
492
493 return videoFileJson
494 })
495 .sort((a, b) => {
496 if (a.resolution < b.resolution) return 1
497 if (a.resolution === b.resolution) return 0
498 return -1
499 })
93e1258c 500
aaf61f38
C
501 return json
502}
503
6fcd19ba 504toAddRemoteJSON = function (this: VideoInstance) {
4d324488 505 // Get thumbnail data to send to the other pod
65fcc311 506 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
aaf61f38 507
6fcd19ba 508 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
aaf61f38 509 const remoteVideo = {
0a6658fd 510 uuid: this.uuid,
e02643f3
C
511 name: this.name,
512 category: this.category,
513 licence: this.licence,
514 language: this.language,
515 nsfw: this.nsfw,
516 description: this.description,
e02643f3
C
517 author: this.Author.name,
518 duration: this.duration,
4d324488 519 thumbnailData: thumbnailData.toString('binary'),
6fcd19ba 520 tags: map<TagInstance, string>(this.Tags, 'name'),
e02643f3
C
521 createdAt: this.createdAt,
522 updatedAt: this.updatedAt,
e02643f3
C
523 views: this.views,
524 likes: this.likes,
93e1258c
C
525 dislikes: this.dislikes,
526 files: []
aaf61f38
C
527 }
528
93e1258c
C
529 this.VideoFiles.forEach(videoFile => {
530 remoteVideo.files.push({
531 infoHash: videoFile.infoHash,
532 resolution: videoFile.resolution,
533 extname: videoFile.extname,
534 size: videoFile.size
535 })
536 })
537
6fcd19ba 538 return remoteVideo
aaf61f38
C
539 })
540}
541
70c065d6 542toUpdateRemoteJSON = function (this: VideoInstance) {
7b1f49de 543 const json = {
0a6658fd 544 uuid: this.uuid,
7b1f49de 545 name: this.name,
6e07c3de 546 category: this.category,
6f0c39e2 547 licence: this.licence,
3092476e 548 language: this.language,
31b59b47 549 nsfw: this.nsfw,
7b1f49de 550 description: this.description,
7b1f49de
C
551 author: this.Author.name,
552 duration: this.duration,
6fcd19ba 553 tags: map<TagInstance, string>(this.Tags, 'name'),
7b1f49de 554 createdAt: this.createdAt,
79066fdf 555 updatedAt: this.updatedAt,
d38b8281
C
556 views: this.views,
557 likes: this.likes,
93e1258c
C
558 dislikes: this.dislikes,
559 files: []
7b1f49de
C
560 }
561
93e1258c
C
562 this.VideoFiles.forEach(videoFile => {
563 json.files.push({
564 infoHash: videoFile.infoHash,
565 resolution: videoFile.resolution,
566 extname: videoFile.extname,
567 size: videoFile.size
568 })
569 })
570
7b1f49de
C
571 return json
572}
573
40298b02 574optimizeOriginalVideofile = function (this: VideoInstance) {
65fcc311 575 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
227d02fe 576 const newExtname = '.mp4'
40298b02 577 const inputVideoFile = this.getOriginalFile()
93e1258c
C
578 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
579 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
227d02fe 580
6fcd19ba
C
581 return new Promise<void>((res, rej) => {
582 ffmpeg(videoInputPath)
583 .output(videoOutputPath)
584 .videoCodec('libx264')
585 .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
586 .outputOption('-movflags faststart')
587 .on('error', rej)
588 .on('end', () => {
589
590 return unlinkPromise(videoInputPath)
591 .then(() => {
592 // Important to do this before getVideoFilename() to take in account the new file extension
93e1258c 593 inputVideoFile.set('extname', newExtname)
6fcd19ba 594
93e1258c 595 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
227d02fe 596 })
40298b02
C
597 .then(() => {
598 return statPromise(this.getVideoFilePath(inputVideoFile))
599 })
600 .then(stats => {
601 return inputVideoFile.set('size', stats.size)
602 })
6fcd19ba 603 .then(() => {
93e1258c 604 return this.createTorrentAndSetInfoHash(inputVideoFile)
6fcd19ba
C
605 })
606 .then(() => {
93e1258c 607 return inputVideoFile.save()
6fcd19ba
C
608 })
609 .then(() => {
610 return res()
611 })
612 .catch(err => {
b0f9f39e 613 // Auto destruction...
93e1258c 614 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
227d02fe 615
6fcd19ba
C
616 return rej(err)
617 })
227d02fe 618 })
6fcd19ba
C
619 .run()
620 })
227d02fe
C
621}
622
40298b02
C
623transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
624 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
625 const extname = '.mp4'
626
627 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
628 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
629
630 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
631 resolution,
632 extname,
633 size: 0,
634 videoId: this.id
635 })
636 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
ecb455b6 637 const resolutionOption = `${resolution}x?` // '720x?' for example
40298b02
C
638
639 return new Promise<void>((res, rej) => {
640 ffmpeg(videoInputPath)
641 .output(videoOutputPath)
642 .videoCodec('libx264')
ecb455b6 643 .size(resolutionOption)
40298b02
C
644 .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
645 .outputOption('-movflags faststart')
646 .on('error', rej)
647 .on('end', () => {
648 return statPromise(videoOutputPath)
649 .then(stats => {
650 newVideoFile.set('size', stats.size)
651
652 return undefined
653 })
654 .then(() => {
655 return this.createTorrentAndSetInfoHash(newVideoFile)
656 })
657 .then(() => {
658 return newVideoFile.save()
659 })
660 .then(() => {
661 return this.VideoFiles.push(newVideoFile)
662 })
663 .then(() => {
664 return res()
665 })
666 .catch(rej)
667 })
668 .run()
669 })
670}
671
672getOriginalFileHeight = function (this: VideoInstance) {
673 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
674
675 return new Promise<number>((res, rej) => {
676 ffmpeg.ffprobe(originalFilePath, (err, metadata) => {
677 if (err) return rej(err)
678
679 const videoStream = metadata.streams.find(s => s.codec_type === 'video')
680 return res(videoStream.height)
681 })
682 })
683}
684
93e1258c
C
685removeThumbnail = function (this: VideoInstance) {
686 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
687 return unlinkPromise(thumbnailPath)
688}
689
690removePreview = function (this: VideoInstance) {
691 // Same name than video thumbnail
692 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
693}
694
695removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
696 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
697 return unlinkPromise(filePath)
698}
699
700removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
b0f9f39e
C
701 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
702 return unlinkPromise(torrentPath)
93e1258c
C
703}
704
aaf61f38
C
705// ------------------------------ STATICS ------------------------------
706
6fcd19ba 707generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
c77fa067
C
708 // Creating the thumbnail for a remote video
709
710 const thumbnailName = video.getThumbnailName()
65fcc311 711 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
6fcd19ba
C
712 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
713 return thumbnailName
c77fa067
C
714 })
715}
716
6fcd19ba
C
717getDurationFromFile = function (videoPath: string) {
718 return new Promise<number>((res, rej) => {
075f16ca 719 ffmpeg.ffprobe(videoPath, (err, metadata) => {
6fcd19ba 720 if (err) return rej(err)
aaf61f38 721
6fcd19ba
C
722 return res(Math.floor(metadata.format.duration))
723 })
aaf61f38
C
724 })
725}
726
6fcd19ba 727list = function () {
93e1258c
C
728 const query = {
729 include: [ Video['sequelize'].models.VideoFile ]
730 }
731
732 return Video.findAll(query)
b769007f
C
733}
734
6fcd19ba 735listForApi = function (start: number, count: number, sort: string) {
556ddc31 736 // Exclude blacklisted videos from the list
feb4bdfd 737 const query = {
e02643f3 738 distinct: true,
feb4bdfd
C
739 offset: start,
740 limit: count,
e02643f3 741 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
feb4bdfd
C
742 include: [
743 {
e02643f3
C
744 model: Video['sequelize'].models.Author,
745 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
7920c273 746 },
93e1258c
C
747 Video['sequelize'].models.Tag,
748 Video['sequelize'].models.VideoFile
198b205c 749 ],
e02643f3 750 where: createBaseVideosWhere()
feb4bdfd
C
751 }
752
6fcd19ba
C
753 return Video.findAndCountAll(query).then(({ rows, count }) => {
754 return {
755 data: rows,
756 total: count
757 }
feb4bdfd 758 })
aaf61f38
C
759}
760
0a6658fd 761loadByHostAndUUID = function (fromHost: string, uuid: string) {
feb4bdfd
C
762 const query = {
763 where: {
0a6658fd 764 uuid
feb4bdfd
C
765 },
766 include: [
93e1258c
C
767 {
768 model: Video['sequelize'].models.VideoFile
769 },
feb4bdfd 770 {
e02643f3 771 model: Video['sequelize'].models.Author,
feb4bdfd
C
772 include: [
773 {
e02643f3 774 model: Video['sequelize'].models.Pod,
7920c273 775 required: true,
feb4bdfd
C
776 where: {
777 host: fromHost
778 }
779 }
780 ]
781 }
782 ]
783 }
aaf61f38 784
6fcd19ba 785 return Video.findOne(query)
aaf61f38
C
786}
787
6fcd19ba 788listOwnedAndPopulateAuthorAndTags = function () {
feb4bdfd
C
789 const query = {
790 where: {
0a6658fd 791 remote: false
feb4bdfd 792 },
93e1258c
C
793 include: [
794 Video['sequelize'].models.VideoFile,
795 Video['sequelize'].models.Author,
796 Video['sequelize'].models.Tag
797 ]
feb4bdfd
C
798 }
799
6fcd19ba 800 return Video.findAll(query)
aaf61f38
C
801}
802
6fcd19ba 803listOwnedByAuthor = function (author: string) {
feb4bdfd
C
804 const query = {
805 where: {
0a6658fd 806 remote: false
feb4bdfd
C
807 },
808 include: [
93e1258c
C
809 {
810 model: Video['sequelize'].models.VideoFile
811 },
feb4bdfd 812 {
e02643f3 813 model: Video['sequelize'].models.Author,
feb4bdfd
C
814 where: {
815 name: author
816 }
817 }
818 ]
819 }
9bd26629 820
6fcd19ba 821 return Video.findAll(query)
aaf61f38
C
822}
823
0a6658fd 824load = function (id: number) {
6fcd19ba 825 return Video.findById(id)
feb4bdfd
C
826}
827
0a6658fd
C
828loadByUUID = function (uuid: string) {
829 const query = {
830 where: {
831 uuid
93e1258c
C
832 },
833 include: [ Video['sequelize'].models.VideoFile ]
0a6658fd
C
834 }
835 return Video.findOne(query)
836}
837
838loadAndPopulateAuthor = function (id: number) {
feb4bdfd 839 const options = {
93e1258c 840 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
feb4bdfd
C
841 }
842
6fcd19ba 843 return Video.findById(id, options)
feb4bdfd
C
844}
845
0a6658fd 846loadAndPopulateAuthorAndPodAndTags = function (id: number) {
feb4bdfd
C
847 const options = {
848 include: [
849 {
e02643f3
C
850 model: Video['sequelize'].models.Author,
851 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
7920c273 852 },
93e1258c
C
853 Video['sequelize'].models.Tag,
854 Video['sequelize'].models.VideoFile
feb4bdfd
C
855 ]
856 }
857
6fcd19ba 858 return Video.findById(id, options)
aaf61f38
C
859}
860
0a6658fd
C
861loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
862 const options = {
863 where: {
864 uuid
865 },
866 include: [
867 {
868 model: Video['sequelize'].models.Author,
869 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
870 },
93e1258c
C
871 Video['sequelize'].models.Tag,
872 Video['sequelize'].models.VideoFile
0a6658fd
C
873 ]
874 }
875
876 return Video.findOne(options)
877}
878
6fcd19ba 879searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
e6d4b0ff 880 const podInclude: Sequelize.IncludeOptions = {
e02643f3 881 model: Video['sequelize'].models.Pod,
7920c273 882 required: false
feb4bdfd 883 }
7920c273 884
e6d4b0ff 885 const authorInclude: Sequelize.IncludeOptions = {
e02643f3 886 model: Video['sequelize'].models.Author,
feb4bdfd
C
887 include: [
888 podInclude
889 ]
890 }
891
e6d4b0ff 892 const tagInclude: Sequelize.IncludeOptions = {
e02643f3 893 model: Video['sequelize'].models.Tag
7920c273
C
894 }
895
93e1258c
C
896 const videoFileInclude: Sequelize.IncludeOptions = {
897 model: Video['sequelize'].models.VideoFile
898 }
899
556ddc31 900 const query: Sequelize.FindOptions<VideoAttributes> = {
e02643f3
C
901 distinct: true,
902 where: createBaseVideosWhere(),
feb4bdfd
C
903 offset: start,
904 limit: count,
e02643f3 905 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
feb4bdfd
C
906 }
907
aaf61f38 908 // Make an exact search with the magnet
55723d16 909 if (field === 'magnetUri') {
93e1258c
C
910 videoFileInclude.where = {
911 infoHash: magnetUtil.decode(value).infoHash
912 }
55723d16 913 } else if (field === 'tags') {
e02643f3 914 const escapedValue = Video['sequelize'].escape('%' + value + '%')
e6d4b0ff 915 query.where['id'].$in = Video['sequelize'].literal(
6fcd19ba
C
916 `(SELECT "VideoTags"."videoId"
917 FROM "Tags"
918 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
18c8e945 919 WHERE name ILIKE ${escapedValue}
6fcd19ba 920 )`
198b205c 921 )
7920c273
C
922 } else if (field === 'host') {
923 // FIXME: Include our pod? (not stored in the database)
924 podInclude.where = {
925 host: {
18c8e945 926 $iLike: '%' + value + '%'
feb4bdfd 927 }
feb4bdfd 928 }
7920c273 929 podInclude.required = true
feb4bdfd 930 } else if (field === 'author') {
7920c273
C
931 authorInclude.where = {
932 name: {
18c8e945 933 $iLike: '%' + value + '%'
feb4bdfd
C
934 }
935 }
7920c273
C
936
937 // authorInclude.or = true
aaf61f38 938 } else {
feb4bdfd 939 query.where[field] = {
18c8e945 940 $iLike: '%' + value + '%'
feb4bdfd 941 }
aaf61f38
C
942 }
943
7920c273 944 query.include = [
93e1258c 945 authorInclude, tagInclude, videoFileInclude
7920c273
C
946 ]
947
6fcd19ba
C
948 return Video.findAndCountAll(query).then(({ rows, count }) => {
949 return {
950 data: rows,
951 total: count
952 }
feb4bdfd 953 })
aaf61f38
C
954}
955
aaf61f38
C
956// ---------------------------------------------------------------------------
957
15d4ee04
C
958function createBaseVideosWhere () {
959 return {
960 id: {
e02643f3 961 $notIn: Video['sequelize'].literal(
15d4ee04
C
962 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
963 )
964 }
965 }
966}
967
6fcd19ba 968function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
e6d4b0ff 969 const options = {
f285faa0 970 filename: imageName,
558d7c23
C
971 count: 1,
972 folder
973 }
aaf61f38 974
69818c93 975 if (size) {
e6d4b0ff 976 options['size'] = size
558d7c23
C
977 }
978
6fcd19ba
C
979 return new Promise<string>((res, rej) => {
980 ffmpeg(videoPath)
981 .on('error', rej)
075f16ca 982 .on('end', () => res(imageName))
6fcd19ba
C
983 .thumbnail(options)
984 })
aaf61f38 985}