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