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