]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
592fc2d595801b7549146132d6c24a793dd7dc1e
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
1 import { map, maxBy, truncate } from 'lodash'
2 import * as magnetUtil from 'magnet-uri'
3 import * as parseTorrent from 'parse-torrent'
4 import { join } from 'path'
5 import * as safeBuffer from 'safe-buffer'
6 import * as Sequelize from 'sequelize'
7 import { VideoPrivacy, VideoResolution } from '../../../shared'
8 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
9 import {
10 createTorrentPromise,
11 generateImageFromVideoFile,
12 getVideoFileHeight,
13 isVideoCategoryValid,
14 isVideoDescriptionValid,
15 isVideoDurationValid,
16 isVideoLanguageValid,
17 isVideoLicenceValid,
18 isVideoNameValid,
19 isVideoNSFWValid,
20 isVideoPrivacyValid,
21 logger,
22 renamePromise,
23 statPromise,
24 transcode,
25 unlinkPromise,
26 writeFilePromise
27 } from '../../helpers'
28 import { isVideoUrlValid } from '../../helpers/custom-validators/videos'
29 import {
30 API_VERSION,
31 CONFIG,
32 CONSTRAINTS_FIELDS,
33 PREVIEWS_SIZE,
34 REMOTE_SCHEME,
35 STATIC_PATHS,
36 THUMBNAILS_SIZE,
37 VIDEO_CATEGORIES,
38 VIDEO_LANGUAGES,
39 VIDEO_LICENCES,
40 VIDEO_PRIVACIES
41 } from '../../initializers'
42
43 import { addMethodsToModel, getSort } from '../utils'
44
45 import { TagInstance } from './tag-interface'
46 import { VideoFileInstance, VideoFileModel } from './video-file-interface'
47 import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface'
48 import { sendDeleteVideo } from '../../lib/index'
49 import * as Bluebird from 'bluebird'
50 import { activityPubCollection } from '../../helpers/activitypub'
51
52 const Buffer = safeBuffer.Buffer
53
54 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
55 let getOriginalFile: VideoMethods.GetOriginalFile
56 let getVideoFilename: VideoMethods.GetVideoFilename
57 let getThumbnailName: VideoMethods.GetThumbnailName
58 let getThumbnailPath: VideoMethods.GetThumbnailPath
59 let getPreviewName: VideoMethods.GetPreviewName
60 let getPreviewPath: VideoMethods.GetPreviewPath
61 let getTorrentFileName: VideoMethods.GetTorrentFileName
62 let isOwned: VideoMethods.IsOwned
63 let toFormattedJSON: VideoMethods.ToFormattedJSON
64 let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
65 let toActivityPubObject: VideoMethods.ToActivityPubObject
66 let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
67 let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
68 let createPreview: VideoMethods.CreatePreview
69 let createThumbnail: VideoMethods.CreateThumbnail
70 let getVideoFilePath: VideoMethods.GetVideoFilePath
71 let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
72 let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
73 let getEmbedPath: VideoMethods.GetEmbedPath
74 let getDescriptionPath: VideoMethods.GetDescriptionPath
75 let getTruncatedDescription: VideoMethods.GetTruncatedDescription
76 let getCategoryLabel: VideoMethods.GetCategoryLabel
77 let getLicenceLabel: VideoMethods.GetLicenceLabel
78 let getLanguageLabel: VideoMethods.GetLanguageLabel
79
80 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
81 let list: VideoMethods.List
82 let listForApi: VideoMethods.ListForApi
83 let listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox
84 let listUserVideosForApi: VideoMethods.ListUserVideosForApi
85 let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
86 let listOwnedAndPopulateAccountAndTags: VideoMethods.ListOwnedAndPopulateAccountAndTags
87 let listOwnedByAccount: VideoMethods.ListOwnedByAccount
88 let load: VideoMethods.Load
89 let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount
90 let loadByUUID: VideoMethods.LoadByUUID
91 let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
92 let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
93 let loadAndPopulateAccount: VideoMethods.LoadAndPopulateAccount
94 let loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags
95 let loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags
96 let searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags
97 let removeThumbnail: VideoMethods.RemoveThumbnail
98 let removePreview: VideoMethods.RemovePreview
99 let removeFile: VideoMethods.RemoveFile
100 let removeTorrent: VideoMethods.RemoveTorrent
101
102 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
103 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
104 {
105 uuid: {
106 type: DataTypes.UUID,
107 defaultValue: DataTypes.UUIDV4,
108 allowNull: false,
109 validate: {
110 isUUID: 4
111 }
112 },
113 name: {
114 type: DataTypes.STRING,
115 allowNull: false,
116 validate: {
117 nameValid: value => {
118 const res = isVideoNameValid(value)
119 if (res === false) throw new Error('Video name is not valid.')
120 }
121 }
122 },
123 category: {
124 type: DataTypes.INTEGER,
125 allowNull: false,
126 validate: {
127 categoryValid: value => {
128 const res = isVideoCategoryValid(value)
129 if (res === false) throw new Error('Video category is not valid.')
130 }
131 }
132 },
133 licence: {
134 type: DataTypes.INTEGER,
135 allowNull: false,
136 defaultValue: null,
137 validate: {
138 licenceValid: value => {
139 const res = isVideoLicenceValid(value)
140 if (res === false) throw new Error('Video licence is not valid.')
141 }
142 }
143 },
144 language: {
145 type: DataTypes.INTEGER,
146 allowNull: true,
147 validate: {
148 languageValid: value => {
149 const res = isVideoLanguageValid(value)
150 if (res === false) throw new Error('Video language is not valid.')
151 }
152 }
153 },
154 privacy: {
155 type: DataTypes.INTEGER,
156 allowNull: false,
157 validate: {
158 privacyValid: value => {
159 const res = isVideoPrivacyValid(value)
160 if (res === false) throw new Error('Video privacy is not valid.')
161 }
162 }
163 },
164 nsfw: {
165 type: DataTypes.BOOLEAN,
166 allowNull: false,
167 validate: {
168 nsfwValid: value => {
169 const res = isVideoNSFWValid(value)
170 if (res === false) throw new Error('Video nsfw attribute is not valid.')
171 }
172 }
173 },
174 description: {
175 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max),
176 allowNull: false,
177 validate: {
178 descriptionValid: value => {
179 const res = isVideoDescriptionValid(value)
180 if (res === false) throw new Error('Video description is not valid.')
181 }
182 }
183 },
184 duration: {
185 type: DataTypes.INTEGER,
186 allowNull: false,
187 validate: {
188 durationValid: value => {
189 const res = isVideoDurationValid(value)
190 if (res === false) throw new Error('Video duration is not valid.')
191 }
192 }
193 },
194 views: {
195 type: DataTypes.INTEGER,
196 allowNull: false,
197 defaultValue: 0,
198 validate: {
199 min: 0,
200 isInt: true
201 }
202 },
203 likes: {
204 type: DataTypes.INTEGER,
205 allowNull: false,
206 defaultValue: 0,
207 validate: {
208 min: 0,
209 isInt: true
210 }
211 },
212 dislikes: {
213 type: DataTypes.INTEGER,
214 allowNull: false,
215 defaultValue: 0,
216 validate: {
217 min: 0,
218 isInt: true
219 }
220 },
221 remote: {
222 type: DataTypes.BOOLEAN,
223 allowNull: false,
224 defaultValue: false
225 },
226 url: {
227 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max),
228 allowNull: false,
229 validate: {
230 urlValid: value => {
231 const res = isVideoUrlValid(value)
232 if (res === false) throw new Error('Video URL is not valid.')
233 }
234 }
235 }
236 },
237 {
238 indexes: [
239 {
240 fields: [ 'name' ]
241 },
242 {
243 fields: [ 'createdAt' ]
244 },
245 {
246 fields: [ 'duration' ]
247 },
248 {
249 fields: [ 'views' ]
250 },
251 {
252 fields: [ 'likes' ]
253 },
254 {
255 fields: [ 'uuid' ]
256 },
257 {
258 fields: [ 'channelId' ]
259 }
260 ],
261 hooks: {
262 afterDestroy
263 }
264 }
265 )
266
267 const classMethods = [
268 associate,
269
270 generateThumbnailFromData,
271 list,
272 listAllAndSharedByAccountForOutbox,
273 listForApi,
274 listUserVideosForApi,
275 listOwnedAndPopulateAccountAndTags,
276 listOwnedByAccount,
277 load,
278 loadByUrlAndPopulateAccount,
279 loadAndPopulateAccount,
280 loadAndPopulateAccountAndServerAndTags,
281 loadByHostAndUUID,
282 loadByUUIDOrURL,
283 loadByUUID,
284 loadLocalVideoByUUID,
285 loadByUUIDAndPopulateAccountAndServerAndTags,
286 searchAndPopulateAccountAndServerAndTags
287 ]
288 const instanceMethods = [
289 createPreview,
290 createThumbnail,
291 createTorrentAndSetInfoHash,
292 getPreviewName,
293 getPreviewPath,
294 getThumbnailName,
295 getThumbnailPath,
296 getTorrentFileName,
297 getVideoFilename,
298 getVideoFilePath,
299 getOriginalFile,
300 isOwned,
301 removeFile,
302 removePreview,
303 removeThumbnail,
304 removeTorrent,
305 toActivityPubObject,
306 toFormattedJSON,
307 toFormattedDetailsJSON,
308 optimizeOriginalVideofile,
309 transcodeOriginalVideofile,
310 getOriginalFileHeight,
311 getEmbedPath,
312 getTruncatedDescription,
313 getDescriptionPath,
314 getCategoryLabel,
315 getLicenceLabel,
316 getLanguageLabel
317 ]
318 addMethodsToModel(Video, classMethods, instanceMethods)
319
320 return Video
321 }
322
323 // ------------------------------ METHODS ------------------------------
324
325 function associate (models) {
326 Video.belongsTo(models.VideoChannel, {
327 foreignKey: {
328 name: 'channelId',
329 allowNull: false
330 },
331 onDelete: 'cascade'
332 })
333
334 Video.belongsToMany(models.Tag, {
335 foreignKey: 'videoId',
336 through: models.VideoTag,
337 onDelete: 'cascade'
338 })
339
340 Video.hasMany(models.VideoAbuse, {
341 foreignKey: {
342 name: 'videoId',
343 allowNull: false
344 },
345 onDelete: 'cascade'
346 })
347
348 Video.hasMany(models.VideoFile, {
349 foreignKey: {
350 name: 'videoId',
351 allowNull: false
352 },
353 onDelete: 'cascade'
354 })
355
356 Video.hasMany(models.VideoShare, {
357 foreignKey: {
358 name: 'videoId',
359 allowNull: false
360 },
361 onDelete: 'cascade'
362 })
363
364 Video.hasMany(models.AccountVideoRate, {
365 foreignKey: {
366 name: 'videoId',
367 allowNull: false
368 },
369 onDelete: 'cascade'
370 })
371 }
372
373 function afterDestroy (video: VideoInstance) {
374 const tasks = []
375
376 tasks.push(
377 video.removeThumbnail()
378 )
379
380 if (video.isOwned()) {
381 tasks.push(
382 video.removePreview(),
383 sendDeleteVideo(video, undefined)
384 )
385
386 // Remove physical files and torrents
387 video.VideoFiles.forEach(file => {
388 tasks.push(video.removeFile(file))
389 tasks.push(video.removeTorrent(file))
390 })
391 }
392
393 return Promise.all(tasks)
394 .catch(err => {
395 logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
396 })
397 }
398
399 getOriginalFile = function (this: VideoInstance) {
400 if (Array.isArray(this.VideoFiles) === false) return undefined
401
402 // The original file is the file that have the higher resolution
403 return maxBy(this.VideoFiles, file => file.resolution)
404 }
405
406 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
407 return this.uuid + '-' + videoFile.resolution + videoFile.extname
408 }
409
410 getThumbnailName = function (this: VideoInstance) {
411 // We always have a copy of the thumbnail
412 const extension = '.jpg'
413 return this.uuid + extension
414 }
415
416 getPreviewName = function (this: VideoInstance) {
417 const extension = '.jpg'
418 return this.uuid + extension
419 }
420
421 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
422 const extension = '.torrent'
423 return this.uuid + '-' + videoFile.resolution + extension
424 }
425
426 isOwned = function (this: VideoInstance) {
427 return this.remote === false
428 }
429
430 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
431 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
432
433 return generateImageFromVideoFile(
434 this.getVideoFilePath(videoFile),
435 CONFIG.STORAGE.PREVIEWS_DIR,
436 this.getPreviewName(),
437 imageSize
438 )
439 }
440
441 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
442 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
443
444 return generateImageFromVideoFile(
445 this.getVideoFilePath(videoFile),
446 CONFIG.STORAGE.THUMBNAILS_DIR,
447 this.getThumbnailName(),
448 imageSize
449 )
450 }
451
452 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
453 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
454 }
455
456 createTorrentAndSetInfoHash = async function (this: VideoInstance, videoFile: VideoFileInstance) {
457 const options = {
458 announceList: [
459 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
460 ],
461 urlList: [
462 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
463 ]
464 }
465
466 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
467
468 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
469 logger.info('Creating torrent %s.', filePath)
470
471 await writeFilePromise(filePath, torrent)
472
473 const parsedTorrent = parseTorrent(torrent)
474 videoFile.infoHash = parsedTorrent.infoHash
475 }
476
477 getEmbedPath = function (this: VideoInstance) {
478 return '/videos/embed/' + this.uuid
479 }
480
481 getThumbnailPath = function (this: VideoInstance) {
482 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
483 }
484
485 getPreviewPath = function (this: VideoInstance) {
486 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
487 }
488
489 toFormattedJSON = function (this: VideoInstance) {
490 let serverHost
491
492 if (this.VideoChannel.Account.Server) {
493 serverHost = this.VideoChannel.Account.Server.host
494 } else {
495 // It means it's our video
496 serverHost = CONFIG.WEBSERVER.HOST
497 }
498
499 const json = {
500 id: this.id,
501 uuid: this.uuid,
502 name: this.name,
503 category: this.category,
504 categoryLabel: this.getCategoryLabel(),
505 licence: this.licence,
506 licenceLabel: this.getLicenceLabel(),
507 language: this.language,
508 languageLabel: this.getLanguageLabel(),
509 nsfw: this.nsfw,
510 description: this.getTruncatedDescription(),
511 serverHost,
512 isLocal: this.isOwned(),
513 account: this.VideoChannel.Account.name,
514 duration: this.duration,
515 views: this.views,
516 likes: this.likes,
517 dislikes: this.dislikes,
518 tags: map<TagInstance, string>(this.Tags, 'name'),
519 thumbnailPath: this.getThumbnailPath(),
520 previewPath: this.getPreviewPath(),
521 embedPath: this.getEmbedPath(),
522 createdAt: this.createdAt,
523 updatedAt: this.updatedAt
524 }
525
526 return json
527 }
528
529 toFormattedDetailsJSON = function (this: VideoInstance) {
530 const formattedJson = this.toFormattedJSON()
531
532 // Maybe our server is not up to date and there are new privacy settings since our version
533 let privacyLabel = VIDEO_PRIVACIES[this.privacy]
534 if (!privacyLabel) privacyLabel = 'Unknown'
535
536 const detailsJson = {
537 privacyLabel,
538 privacy: this.privacy,
539 descriptionPath: this.getDescriptionPath(),
540 channel: this.VideoChannel.toFormattedJSON(),
541 files: []
542 }
543
544 // Format and sort video files
545 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
546 detailsJson.files = this.VideoFiles
547 .map(videoFile => {
548 let resolutionLabel = videoFile.resolution + 'p'
549
550 const videoFileJson = {
551 resolution: videoFile.resolution,
552 resolutionLabel,
553 magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
554 size: videoFile.size,
555 torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
556 fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
557 }
558
559 return videoFileJson
560 })
561 .sort((a, b) => {
562 if (a.resolution < b.resolution) return 1
563 if (a.resolution === b.resolution) return 0
564 return -1
565 })
566
567 return Object.assign(formattedJson, detailsJson)
568 }
569
570 toActivityPubObject = function (this: VideoInstance) {
571 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
572 if (!this.Tags) this.Tags = []
573
574 const tag = this.Tags.map(t => ({
575 type: 'Hashtag' as 'Hashtag',
576 name: t.name
577 }))
578
579 let language
580 if (this.language) {
581 language = {
582 identifier: this.language + '',
583 name: this.getLanguageLabel()
584 }
585 }
586
587 let likesObject
588 let dislikesObject
589
590 if (Array.isArray(this.AccountVideoRates)) {
591 const likes: string[] = []
592 const dislikes: string[] = []
593
594 for (const rate of this.AccountVideoRates) {
595 if (rate.type === 'like') {
596 likes.push(rate.Account.url)
597 } else if (rate.type === 'dislike') {
598 dislikes.push(rate.Account.url)
599 }
600 }
601
602 likesObject = activityPubCollection(likes)
603 dislikesObject = activityPubCollection(dislikes)
604 }
605
606 const url = []
607 for (const file of this.VideoFiles) {
608 url.push({
609 type: 'Link',
610 mimeType: 'video/' + file.extname.replace('.', ''),
611 url: getVideoFileUrl(this, file, baseUrlHttp),
612 width: file.resolution,
613 size: file.size
614 })
615
616 url.push({
617 type: 'Link',
618 mimeType: 'application/x-bittorrent',
619 url: getTorrentUrl(this, file, baseUrlHttp),
620 width: file.resolution
621 })
622
623 url.push({
624 type: 'Link',
625 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
626 url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
627 width: file.resolution
628 })
629 }
630
631 const videoObject: VideoTorrentObject = {
632 type: 'Video' as 'Video',
633 id: this.url,
634 name: this.name,
635 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
636 duration: 'PT' + this.duration + 'S',
637 uuid: this.uuid,
638 tag,
639 category: {
640 identifier: this.category + '',
641 name: this.getCategoryLabel()
642 },
643 licence: {
644 identifier: this.licence + '',
645 name: this.getLicenceLabel()
646 },
647 language,
648 views: this.views,
649 nsfw: this.nsfw,
650 published: this.createdAt.toISOString(),
651 updated: this.updatedAt.toISOString(),
652 mediaType: 'text/markdown',
653 content: this.getTruncatedDescription(),
654 icon: {
655 type: 'Image',
656 url: getThumbnailUrl(this, baseUrlHttp),
657 mediaType: 'image/jpeg',
658 width: THUMBNAILS_SIZE.width,
659 height: THUMBNAILS_SIZE.height
660 },
661 url,
662 likes: likesObject,
663 dislikes: dislikesObject
664 }
665
666 return videoObject
667 }
668
669 getTruncatedDescription = function (this: VideoInstance) {
670 const options = {
671 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
672 }
673
674 return truncate(this.description, options)
675 }
676
677 optimizeOriginalVideofile = async function (this: VideoInstance) {
678 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
679 const newExtname = '.mp4'
680 const inputVideoFile = this.getOriginalFile()
681 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
682 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
683
684 const transcodeOptions = {
685 inputPath: videoInputPath,
686 outputPath: videoOutputPath
687 }
688
689 try {
690 // Could be very long!
691 await transcode(transcodeOptions)
692
693 await unlinkPromise(videoInputPath)
694
695 // Important to do this before getVideoFilename() to take in account the new file extension
696 inputVideoFile.set('extname', newExtname)
697
698 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
699 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
700
701 inputVideoFile.set('size', stats.size)
702
703 await this.createTorrentAndSetInfoHash(inputVideoFile)
704 await inputVideoFile.save()
705
706 } catch (err) {
707 // Auto destruction...
708 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
709
710 throw err
711 }
712 }
713
714 transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
715 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
716 const extname = '.mp4'
717
718 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
719 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
720
721 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
722 resolution,
723 extname,
724 size: 0,
725 videoId: this.id
726 })
727 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
728
729 const transcodeOptions = {
730 inputPath: videoInputPath,
731 outputPath: videoOutputPath,
732 resolution
733 }
734
735 await transcode(transcodeOptions)
736
737 const stats = await statPromise(videoOutputPath)
738
739 newVideoFile.set('size', stats.size)
740
741 await this.createTorrentAndSetInfoHash(newVideoFile)
742
743 await newVideoFile.save()
744
745 this.VideoFiles.push(newVideoFile)
746 }
747
748 getOriginalFileHeight = function (this: VideoInstance) {
749 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
750
751 return getVideoFileHeight(originalFilePath)
752 }
753
754 getDescriptionPath = function (this: VideoInstance) {
755 return `/api/${API_VERSION}/videos/${this.uuid}/description`
756 }
757
758 getCategoryLabel = function (this: VideoInstance) {
759 let categoryLabel = VIDEO_CATEGORIES[this.category]
760
761 // Maybe our server is not up to date and there are new categories since our version
762 if (!categoryLabel) categoryLabel = 'Misc'
763
764 return categoryLabel
765 }
766
767 getLicenceLabel = function (this: VideoInstance) {
768 let licenceLabel = VIDEO_LICENCES[this.licence]
769
770 // Maybe our server is not up to date and there are new licences since our version
771 if (!licenceLabel) licenceLabel = 'Unknown'
772
773 return licenceLabel
774 }
775
776 getLanguageLabel = function (this: VideoInstance) {
777 // Language is an optional attribute
778 let languageLabel = VIDEO_LANGUAGES[this.language]
779 if (!languageLabel) languageLabel = 'Unknown'
780
781 return languageLabel
782 }
783
784 removeThumbnail = function (this: VideoInstance) {
785 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
786 return unlinkPromise(thumbnailPath)
787 }
788
789 removePreview = function (this: VideoInstance) {
790 // Same name than video thumbnail
791 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
792 }
793
794 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
795 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
796 return unlinkPromise(filePath)
797 }
798
799 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
800 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
801 return unlinkPromise(torrentPath)
802 }
803
804 // ------------------------------ STATICS ------------------------------
805
806 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
807 // Creating the thumbnail for a remote video
808
809 const thumbnailName = video.getThumbnailName()
810 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
811 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
812 return thumbnailName
813 })
814 }
815
816 list = function () {
817 const query = {
818 include: [ Video['sequelize'].models.VideoFile ]
819 }
820
821 return Video.findAll(query)
822 }
823
824 listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) {
825 function getRawQuery (select: string) {
826 const queryVideo = 'SELECT ' + select + ' FROM "Videos" AS "Video" ' +
827 'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
828 'WHERE "VideoChannel"."accountId" = ' + accountId
829 const queryVideoShare = 'SELECT ' + select + ' FROM "VideoShares" AS "VideoShare" ' +
830 'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
831 'WHERE "VideoShare"."accountId" = ' + accountId
832
833 let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})`
834
835 return rawQuery
836 }
837
838 const rawQuery = getRawQuery('"Video"."id"')
839 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
840
841 const query = {
842 distinct: true,
843 offset: start,
844 limit: count,
845 order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
846 where: {
847 id: {
848 [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
849 }
850 },
851 include: [
852 {
853 model: Video['sequelize'].models.VideoShare,
854 required: false,
855 where: {
856 [Sequelize.Op.and]: [
857 {
858 id: {
859 [Sequelize.Op.not]: null
860 }
861 },
862 {
863 accountId
864 }
865 ]
866 }
867 },
868 {
869 model: Video['sequelize'].models.VideoChannel,
870 required: true,
871 include: [
872 {
873 model: Video['sequelize'].models.Account,
874 required: true
875 }
876 ]
877 },
878 {
879 model: Video['sequelize'].models.AccountVideoRate,
880 include: [ Video['sequelize'].models.Account ]
881 },
882 Video['sequelize'].models.VideoFile,
883 Video['sequelize'].models.Tag
884 ]
885 }
886
887 return Bluebird.all([
888 Video.findAll(query),
889 Video['sequelize'].query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
890 ]).then(([ rows, totals ]) => {
891 // totals: totalVideos + totalVideoShares
892 let totalVideos = 0
893 let totalVideoShares = 0
894 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
895 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
896
897 const total = totalVideos + totalVideoShares
898 return {
899 data: rows,
900 total: total
901 }
902 })
903 }
904
905 listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) {
906 const query = {
907 distinct: true,
908 offset: start,
909 limit: count,
910 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
911 include: [
912 {
913 model: Video['sequelize'].models.VideoChannel,
914 required: true,
915 include: [
916 {
917 model: Video['sequelize'].models.Account,
918 where: {
919 userId
920 },
921 required: true
922 }
923 ]
924 },
925 Video['sequelize'].models.Tag
926 ]
927 }
928
929 return Video.findAndCountAll(query).then(({ rows, count }) => {
930 return {
931 data: rows,
932 total: count
933 }
934 })
935 }
936
937 listForApi = function (start: number, count: number, sort: string) {
938 const query = {
939 distinct: true,
940 offset: start,
941 limit: count,
942 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
943 include: [
944 {
945 model: Video['sequelize'].models.VideoChannel,
946 required: true,
947 include: [
948 {
949 model: Video['sequelize'].models.Account,
950 required: true,
951 include: [
952 {
953 model: Video['sequelize'].models.Server,
954 required: false
955 }
956 ]
957 }
958 ]
959 },
960 Video['sequelize'].models.Tag
961 ],
962 where: createBaseVideosWhere()
963 }
964
965 return Video.findAndCountAll(query).then(({ rows, count }) => {
966 return {
967 data: rows,
968 total: count
969 }
970 })
971 }
972
973 loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
974 const query: Sequelize.FindOptions<VideoAttributes> = {
975 where: {
976 uuid
977 },
978 include: [
979 {
980 model: Video['sequelize'].models.VideoFile
981 },
982 {
983 model: Video['sequelize'].models.VideoChannel,
984 include: [
985 {
986 model: Video['sequelize'].models.Account,
987 include: [
988 {
989 model: Video['sequelize'].models.Server,
990 required: true,
991 where: {
992 host: fromHost
993 }
994 }
995 ]
996 }
997 ]
998 }
999 ]
1000 }
1001
1002 if (t !== undefined) query.transaction = t
1003
1004 return Video.findOne(query)
1005 }
1006
1007 listOwnedAndPopulateAccountAndTags = function () {
1008 const query = {
1009 where: {
1010 remote: false
1011 },
1012 include: [
1013 Video['sequelize'].models.VideoFile,
1014 {
1015 model: Video['sequelize'].models.VideoChannel,
1016 include: [ Video['sequelize'].models.Account ]
1017 },
1018 Video['sequelize'].models.Tag
1019 ]
1020 }
1021
1022 return Video.findAll(query)
1023 }
1024
1025 listOwnedByAccount = function (account: string) {
1026 const query = {
1027 where: {
1028 remote: false
1029 },
1030 include: [
1031 {
1032 model: Video['sequelize'].models.VideoFile
1033 },
1034 {
1035 model: Video['sequelize'].models.VideoChannel,
1036 include: [
1037 {
1038 model: Video['sequelize'].models.Account,
1039 where: {
1040 name: account
1041 }
1042 }
1043 ]
1044 }
1045 ]
1046 }
1047
1048 return Video.findAll(query)
1049 }
1050
1051 load = function (id: number) {
1052 return Video.findById(id)
1053 }
1054
1055 loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
1056 const query: Sequelize.FindOptions<VideoAttributes> = {
1057 where: {
1058 uuid
1059 },
1060 include: [ Video['sequelize'].models.VideoFile ]
1061 }
1062
1063 if (t !== undefined) query.transaction = t
1064
1065 return Video.findOne(query)
1066 }
1067
1068 loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) {
1069 const query: Sequelize.FindOptions<VideoAttributes> = {
1070 where: {
1071 url
1072 },
1073 include: [
1074 Video['sequelize'].models.VideoFile,
1075 {
1076 model: Video['sequelize'].models.VideoChannel,
1077 include: [ Video['sequelize'].models.Account ]
1078 }
1079 ]
1080 }
1081
1082 if (t !== undefined) query.transaction = t
1083
1084 return Video.findOne(query)
1085 }
1086
1087 loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) {
1088 const query: Sequelize.FindOptions<VideoAttributes> = {
1089 where: {
1090 [Sequelize.Op.or]: [
1091 { uuid },
1092 { url }
1093 ]
1094 },
1095 include: [ Video['sequelize'].models.VideoFile ]
1096 }
1097
1098 if (t !== undefined) query.transaction = t
1099
1100 return Video.findOne(query)
1101 }
1102
1103 loadLocalVideoByUUID = function (uuid: string, t?: Sequelize.Transaction) {
1104 const query: Sequelize.FindOptions<VideoAttributes> = {
1105 where: {
1106 uuid,
1107 remote: false
1108 },
1109 include: [ Video['sequelize'].models.VideoFile ]
1110 }
1111
1112 if (t !== undefined) query.transaction = t
1113
1114 return Video.findOne(query)
1115 }
1116
1117 loadAndPopulateAccount = function (id: number) {
1118 const options = {
1119 include: [
1120 Video['sequelize'].models.VideoFile,
1121 {
1122 model: Video['sequelize'].models.VideoChannel,
1123 include: [ Video['sequelize'].models.Account ]
1124 }
1125 ]
1126 }
1127
1128 return Video.findById(id, options)
1129 }
1130
1131 loadAndPopulateAccountAndServerAndTags = function (id: number) {
1132 const options = {
1133 include: [
1134 {
1135 model: Video['sequelize'].models.VideoChannel,
1136 include: [
1137 {
1138 model: Video['sequelize'].models.Account,
1139 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1140 }
1141 ]
1142 },
1143 {
1144 model: Video['sequelize'].models.AccountVideoRate,
1145 include: [ Video['sequelize'].models.Account ]
1146 },
1147 Video['sequelize'].models.Tag,
1148 Video['sequelize'].models.VideoFile
1149 ]
1150 }
1151
1152 return Video.findById(id, options)
1153 }
1154
1155 loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
1156 const options = {
1157 where: {
1158 uuid
1159 },
1160 include: [
1161 {
1162 model: Video['sequelize'].models.VideoChannel,
1163 include: [
1164 {
1165 model: Video['sequelize'].models.Account,
1166 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1167 }
1168 ]
1169 },
1170 {
1171 model: Video['sequelize'].models.AccountVideoRate,
1172 include: [ Video['sequelize'].models.Account ]
1173 },
1174 Video['sequelize'].models.Tag,
1175 Video['sequelize'].models.VideoFile
1176 ]
1177 }
1178
1179 return Video.findOne(options)
1180 }
1181
1182 searchAndPopulateAccountAndServerAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
1183 const serverInclude: Sequelize.IncludeOptions = {
1184 model: Video['sequelize'].models.Server,
1185 required: false
1186 }
1187
1188 const accountInclude: Sequelize.IncludeOptions = {
1189 model: Video['sequelize'].models.Account,
1190 include: [ serverInclude ]
1191 }
1192
1193 const videoChannelInclude: Sequelize.IncludeOptions = {
1194 model: Video['sequelize'].models.VideoChannel,
1195 include: [ accountInclude ],
1196 required: true
1197 }
1198
1199 const tagInclude: Sequelize.IncludeOptions = {
1200 model: Video['sequelize'].models.Tag
1201 }
1202
1203 const query: Sequelize.FindOptions<VideoAttributes> = {
1204 distinct: true,
1205 where: createBaseVideosWhere(),
1206 offset: start,
1207 limit: count,
1208 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
1209 }
1210
1211 if (field === 'tags') {
1212 const escapedValue = Video['sequelize'].escape('%' + value + '%')
1213 query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
1214 `(SELECT "VideoTags"."videoId"
1215 FROM "Tags"
1216 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
1217 WHERE name ILIKE ${escapedValue}
1218 )`
1219 )
1220 } else if (field === 'host') {
1221 // FIXME: Include our server? (not stored in the database)
1222 serverInclude.where = {
1223 host: {
1224 [Sequelize.Op.iLike]: '%' + value + '%'
1225 }
1226 }
1227 serverInclude.required = true
1228 } else if (field === 'account') {
1229 accountInclude.where = {
1230 name: {
1231 [Sequelize.Op.iLike]: '%' + value + '%'
1232 }
1233 }
1234 } else {
1235 query.where[field] = {
1236 [Sequelize.Op.iLike]: '%' + value + '%'
1237 }
1238 }
1239
1240 query.include = [
1241 videoChannelInclude, tagInclude
1242 ]
1243
1244 return Video.findAndCountAll(query).then(({ rows, count }) => {
1245 return {
1246 data: rows,
1247 total: count
1248 }
1249 })
1250 }
1251
1252 // ---------------------------------------------------------------------------
1253
1254 function createBaseVideosWhere () {
1255 return {
1256 id: {
1257 [Sequelize.Op.notIn]: Video['sequelize'].literal(
1258 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1259 )
1260 },
1261 privacy: VideoPrivacy.PUBLIC
1262 }
1263 }
1264
1265 function getBaseUrls (video: VideoInstance) {
1266 let baseUrlHttp
1267 let baseUrlWs
1268
1269 if (video.isOwned()) {
1270 baseUrlHttp = CONFIG.WEBSERVER.URL
1271 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1272 } else {
1273 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
1274 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
1275 }
1276
1277 return { baseUrlHttp, baseUrlWs }
1278 }
1279
1280 function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1281 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1282 }
1283
1284 function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1285 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1286 }
1287
1288 function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1289 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1290 }
1291
1292 function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1293 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1294 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1295 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1296
1297 const magnetHash = {
1298 xs,
1299 announce,
1300 urlList,
1301 infoHash: videoFile.infoHash,
1302 name: video.name
1303 }
1304
1305 return magnetUtil.encode(magnetHash)
1306 }