]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Fix client search
[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 // Add video url too
620 url.push({
621 type: 'Link',
622 mimeType: 'text/html',
623 url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
624 })
625
626 const videoObject: VideoTorrentObject = {
627 type: 'Video' as 'Video',
628 id: this.url,
629 name: this.name,
630 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
631 duration: 'PT' + this.duration + 'S',
632 uuid: this.uuid,
633 tag,
634 category: {
635 identifier: this.category + '',
636 name: this.getCategoryLabel()
637 },
638 licence: {
639 identifier: this.licence + '',
640 name: this.getLicenceLabel()
641 },
642 language,
643 views: this.views,
644 nsfw: this.nsfw,
645 published: this.createdAt.toISOString(),
646 updated: this.updatedAt.toISOString(),
647 mediaType: 'text/markdown',
648 content: this.getTruncatedDescription(),
649 icon: {
650 type: 'Image',
651 url: getThumbnailUrl(this, baseUrlHttp),
652 mediaType: 'image/jpeg',
653 width: THUMBNAILS_SIZE.width,
654 height: THUMBNAILS_SIZE.height
655 },
656 url,
657 likes: likesObject,
658 dislikes: dislikesObject,
659 shares: sharesObject
660 }
661
662 return videoObject
663 }
664
665 getTruncatedDescription = function (this: VideoInstance) {
666 const options = {
667 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
668 }
669
670 return truncate(this.description, options)
671 }
672
673 optimizeOriginalVideofile = async function (this: VideoInstance) {
674 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
675 const newExtname = '.mp4'
676 const inputVideoFile = this.getOriginalFile()
677 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
678 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
679
680 const transcodeOptions = {
681 inputPath: videoInputPath,
682 outputPath: videoOutputPath
683 }
684
685 try {
686 // Could be very long!
687 await transcode(transcodeOptions)
688
689 await unlinkPromise(videoInputPath)
690
691 // Important to do this before getVideoFilename() to take in account the new file extension
692 inputVideoFile.set('extname', newExtname)
693
694 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
695 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
696
697 inputVideoFile.set('size', stats.size)
698
699 await this.createTorrentAndSetInfoHash(inputVideoFile)
700 await inputVideoFile.save()
701
702 } catch (err) {
703 // Auto destruction...
704 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
705
706 throw err
707 }
708 }
709
710 transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
711 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
712 const extname = '.mp4'
713
714 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
715 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
716
717 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
718 resolution,
719 extname,
720 size: 0,
721 videoId: this.id
722 })
723 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
724
725 const transcodeOptions = {
726 inputPath: videoInputPath,
727 outputPath: videoOutputPath,
728 resolution
729 }
730
731 await transcode(transcodeOptions)
732
733 const stats = await statPromise(videoOutputPath)
734
735 newVideoFile.set('size', stats.size)
736
737 await this.createTorrentAndSetInfoHash(newVideoFile)
738
739 await newVideoFile.save()
740
741 this.VideoFiles.push(newVideoFile)
742 }
743
744 getOriginalFileHeight = function (this: VideoInstance) {
745 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
746
747 return getVideoFileHeight(originalFilePath)
748 }
749
750 getDescriptionPath = function (this: VideoInstance) {
751 return `/api/${API_VERSION}/videos/${this.uuid}/description`
752 }
753
754 getCategoryLabel = function (this: VideoInstance) {
755 let categoryLabel = VIDEO_CATEGORIES[this.category]
756
757 // Maybe our server is not up to date and there are new categories since our version
758 if (!categoryLabel) categoryLabel = 'Misc'
759
760 return categoryLabel
761 }
762
763 getLicenceLabel = function (this: VideoInstance) {
764 let licenceLabel = VIDEO_LICENCES[this.licence]
765
766 // Maybe our server is not up to date and there are new licences since our version
767 if (!licenceLabel) licenceLabel = 'Unknown'
768
769 return licenceLabel
770 }
771
772 getLanguageLabel = function (this: VideoInstance) {
773 // Language is an optional attribute
774 let languageLabel = VIDEO_LANGUAGES[this.language]
775 if (!languageLabel) languageLabel = 'Unknown'
776
777 return languageLabel
778 }
779
780 removeThumbnail = function (this: VideoInstance) {
781 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
782 return unlinkPromise(thumbnailPath)
783 }
784
785 removePreview = function (this: VideoInstance) {
786 // Same name than video thumbnail
787 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
788 }
789
790 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
791 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
792 return unlinkPromise(filePath)
793 }
794
795 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
796 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
797 return unlinkPromise(torrentPath)
798 }
799
800 // ------------------------------ STATICS ------------------------------
801
802 list = function () {
803 const query = {
804 include: [ Video['sequelize'].models.VideoFile ]
805 }
806
807 return Video.findAll(query)
808 }
809
810 listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) {
811 function getRawQuery (select: string) {
812 const queryVideo = 'SELECT ' + select + ' FROM "Videos" AS "Video" ' +
813 'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
814 'WHERE "VideoChannel"."accountId" = ' + accountId
815 const queryVideoShare = 'SELECT ' + select + ' FROM "VideoShares" AS "VideoShare" ' +
816 'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
817 'WHERE "VideoShare"."accountId" = ' + accountId
818
819 let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})`
820
821 return rawQuery
822 }
823
824 const rawQuery = getRawQuery('"Video"."id"')
825 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
826
827 const query = {
828 distinct: true,
829 offset: start,
830 limit: count,
831 order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
832 where: {
833 id: {
834 [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
835 }
836 },
837 include: [
838 {
839 model: Video['sequelize'].models.VideoShare,
840 required: false,
841 where: {
842 [Sequelize.Op.and]: [
843 {
844 id: {
845 [Sequelize.Op.not]: null
846 }
847 },
848 {
849 accountId
850 }
851 ]
852 },
853 include: [ Video['sequelize'].models.Account ]
854 },
855 {
856 model: Video['sequelize'].models.VideoChannel,
857 required: true,
858 include: [
859 {
860 model: Video['sequelize'].models.Account,
861 required: true
862 }
863 ]
864 },
865 {
866 model: Video['sequelize'].models.AccountVideoRate,
867 include: [ Video['sequelize'].models.Account ]
868 },
869 Video['sequelize'].models.VideoFile,
870 Video['sequelize'].models.Tag
871 ]
872 }
873
874 return Bluebird.all([
875 Video.findAll(query),
876 Video['sequelize'].query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
877 ]).then(([ rows, totals ]) => {
878 // totals: totalVideos + totalVideoShares
879 let totalVideos = 0
880 let totalVideoShares = 0
881 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
882 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
883
884 const total = totalVideos + totalVideoShares
885 return {
886 data: rows,
887 total: total
888 }
889 })
890 }
891
892 listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) {
893 const query = {
894 distinct: true,
895 offset: start,
896 limit: count,
897 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
898 include: [
899 {
900 model: Video['sequelize'].models.VideoChannel,
901 required: true,
902 include: [
903 {
904 model: Video['sequelize'].models.Account,
905 where: {
906 userId
907 },
908 required: true
909 }
910 ]
911 },
912 Video['sequelize'].models.Tag
913 ]
914 }
915
916 return Video.findAndCountAll(query).then(({ rows, count }) => {
917 return {
918 data: rows,
919 total: count
920 }
921 })
922 }
923
924 listForApi = function (start: number, count: number, sort: string) {
925 const query = {
926 distinct: true,
927 offset: start,
928 limit: count,
929 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
930 include: [
931 {
932 model: Video['sequelize'].models.VideoChannel,
933 required: true,
934 include: [
935 {
936 model: Video['sequelize'].models.Account,
937 required: true,
938 include: [
939 {
940 model: Video['sequelize'].models.Server,
941 required: false
942 }
943 ]
944 }
945 ]
946 },
947 Video['sequelize'].models.Tag
948 ],
949 where: createBaseVideosWhere()
950 }
951
952 return Video.findAndCountAll(query).then(({ rows, count }) => {
953 return {
954 data: rows,
955 total: count
956 }
957 })
958 }
959
960 load = function (id: number) {
961 return Video.findById(id)
962 }
963
964 loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
965 const query: Sequelize.FindOptions<VideoAttributes> = {
966 where: {
967 uuid
968 },
969 include: [ Video['sequelize'].models.VideoFile ]
970 }
971
972 if (t !== undefined) query.transaction = t
973
974 return Video.findOne(query)
975 }
976
977 loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) {
978 const query: Sequelize.FindOptions<VideoAttributes> = {
979 where: {
980 url
981 },
982 include: [
983 Video['sequelize'].models.VideoFile,
984 {
985 model: Video['sequelize'].models.VideoChannel,
986 include: [ Video['sequelize'].models.Account ]
987 }
988 ]
989 }
990
991 if (t !== undefined) query.transaction = t
992
993 return Video.findOne(query)
994 }
995
996 loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) {
997 const query: Sequelize.FindOptions<VideoAttributes> = {
998 where: {
999 [Sequelize.Op.or]: [
1000 { uuid },
1001 { url }
1002 ]
1003 },
1004 include: [ Video['sequelize'].models.VideoFile ]
1005 }
1006
1007 if (t !== undefined) query.transaction = t
1008
1009 return Video.findOne(query)
1010 }
1011
1012 loadAndPopulateAccountAndServerAndTags = function (id: number) {
1013 const options = {
1014 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
1015 include: [
1016 {
1017 model: Video['sequelize'].models.VideoChannel,
1018 include: [
1019 {
1020 model: Video['sequelize'].models.Account,
1021 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1022 }
1023 ]
1024 },
1025 {
1026 model: Video['sequelize'].models.AccountVideoRate,
1027 include: [ Video['sequelize'].models.Account ]
1028 },
1029 {
1030 model: Video['sequelize'].models.VideoShare,
1031 include: [ Video['sequelize'].models.Account ]
1032 },
1033 Video['sequelize'].models.Tag,
1034 Video['sequelize'].models.VideoFile
1035 ]
1036 }
1037
1038 return Video.findById(id, options)
1039 }
1040
1041 loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
1042 const options = {
1043 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
1044 where: {
1045 uuid
1046 },
1047 include: [
1048 {
1049 model: Video['sequelize'].models.VideoChannel,
1050 include: [
1051 {
1052 model: Video['sequelize'].models.Account,
1053 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1054 }
1055 ]
1056 },
1057 {
1058 model: Video['sequelize'].models.AccountVideoRate,
1059 include: [ Video['sequelize'].models.Account ]
1060 },
1061 {
1062 model: Video['sequelize'].models.VideoShare,
1063 include: [ Video['sequelize'].models.Account ]
1064 },
1065 Video['sequelize'].models.Tag,
1066 Video['sequelize'].models.VideoFile
1067 ]
1068 }
1069
1070 return Video.findOne(options)
1071 }
1072
1073 searchAndPopulateAccountAndServerAndTags = function (value: string, start: number, count: number, sort: string) {
1074 const serverInclude: Sequelize.IncludeOptions = {
1075 model: Video['sequelize'].models.Server,
1076 required: false
1077 }
1078
1079 const accountInclude: Sequelize.IncludeOptions = {
1080 model: Video['sequelize'].models.Account,
1081 include: [ serverInclude ]
1082 }
1083
1084 const videoChannelInclude: Sequelize.IncludeOptions = {
1085 model: Video['sequelize'].models.VideoChannel,
1086 include: [ accountInclude ],
1087 required: true
1088 }
1089
1090 const tagInclude: Sequelize.IncludeOptions = {
1091 model: Video['sequelize'].models.Tag
1092 }
1093
1094 const query: Sequelize.FindOptions<VideoAttributes> = {
1095 distinct: true,
1096 where: createBaseVideosWhere(),
1097 offset: start,
1098 limit: count,
1099 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
1100 }
1101
1102 // TODO: search on tags too
1103 // const escapedValue = Video['sequelize'].escape('%' + value + '%')
1104 // query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
1105 // `(SELECT "VideoTags"."videoId"
1106 // FROM "Tags"
1107 // INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
1108 // WHERE name ILIKE ${escapedValue}
1109 // )`
1110 // )
1111
1112 // TODO: search on account too
1113 // accountInclude.where = {
1114 // name: {
1115 // [Sequelize.Op.iLike]: '%' + value + '%'
1116 // }
1117 // }
1118 query.where['name'] = {
1119 [Sequelize.Op.iLike]: '%' + value + '%'
1120 }
1121
1122 query.include = [
1123 videoChannelInclude, tagInclude
1124 ]
1125
1126 return Video.findAndCountAll(query).then(({ rows, count }) => {
1127 return {
1128 data: rows,
1129 total: count
1130 }
1131 })
1132 }
1133
1134 // ---------------------------------------------------------------------------
1135
1136 function createBaseVideosWhere () {
1137 return {
1138 id: {
1139 [Sequelize.Op.notIn]: Video['sequelize'].literal(
1140 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1141 )
1142 },
1143 privacy: VideoPrivacy.PUBLIC
1144 }
1145 }
1146
1147 function getBaseUrls (video: VideoInstance) {
1148 let baseUrlHttp
1149 let baseUrlWs
1150
1151 if (video.isOwned()) {
1152 baseUrlHttp = CONFIG.WEBSERVER.URL
1153 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1154 } else {
1155 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
1156 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
1157 }
1158
1159 return { baseUrlHttp, baseUrlWs }
1160 }
1161
1162 function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1163 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1164 }
1165
1166 function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1167 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1168 }
1169
1170 function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1171 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1172 }
1173
1174 function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1175 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1176 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1177 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1178
1179 const magnetHash = {
1180 xs,
1181 announce,
1182 urlList,
1183 infoHash: videoFile.infoHash,
1184 name: video.name
1185 }
1186
1187 return magnetUtil.encode(magnetHash)
1188 }