]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Optimise transaction for video upload
[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: true,
108 defaultValue: null,
109 validate: {
110 categoryValid: value => {
111 const res = isVideoCategoryValid(value)
112 if (res === false) throw new Error('Video category is not valid.')
113 }
114 }
115 },
116 licence: {
117 type: DataTypes.INTEGER,
118 allowNull: true,
119 defaultValue: null,
120 validate: {
121 licenceValid: value => {
122 const res = isVideoLicenceValid(value)
123 if (res === false) throw new Error('Video licence is not valid.')
124 }
125 }
126 },
127 language: {
128 type: DataTypes.INTEGER,
129 allowNull: true,
130 defaultValue: null,
131 validate: {
132 languageValid: value => {
133 const res = isVideoLanguageValid(value)
134 if (res === false) throw new Error('Video language is not valid.')
135 }
136 }
137 },
138 privacy: {
139 type: DataTypes.INTEGER,
140 allowNull: false,
141 validate: {
142 privacyValid: value => {
143 const res = isVideoPrivacyValid(value)
144 if (res === false) throw new Error('Video privacy is not valid.')
145 }
146 }
147 },
148 nsfw: {
149 type: DataTypes.BOOLEAN,
150 allowNull: false,
151 validate: {
152 nsfwValid: value => {
153 const res = isVideoNSFWValid(value)
154 if (res === false) throw new Error('Video nsfw attribute is not valid.')
155 }
156 }
157 },
158 description: {
159 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max),
160 allowNull: true,
161 defaultValue: null,
162 validate: {
163 descriptionValid: value => {
164 const res = isVideoDescriptionValid(value)
165 if (res === false) throw new Error('Video description is not valid.')
166 }
167 }
168 },
169 duration: {
170 type: DataTypes.INTEGER,
171 allowNull: false,
172 validate: {
173 durationValid: value => {
174 const res = isVideoDurationValid(value)
175 if (res === false) throw new Error('Video duration is not valid.')
176 }
177 }
178 },
179 views: {
180 type: DataTypes.INTEGER,
181 allowNull: false,
182 defaultValue: 0,
183 validate: {
184 min: 0,
185 isInt: true
186 }
187 },
188 likes: {
189 type: DataTypes.INTEGER,
190 allowNull: false,
191 defaultValue: 0,
192 validate: {
193 min: 0,
194 isInt: true
195 }
196 },
197 dislikes: {
198 type: DataTypes.INTEGER,
199 allowNull: false,
200 defaultValue: 0,
201 validate: {
202 min: 0,
203 isInt: true
204 }
205 },
206 remote: {
207 type: DataTypes.BOOLEAN,
208 allowNull: false,
209 defaultValue: false
210 },
211 url: {
212 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max),
213 allowNull: false,
214 validate: {
215 urlValid: value => {
216 const res = isActivityPubUrlValid(value)
217 if (res === false) throw new Error('Video URL is not valid.')
218 }
219 }
220 }
221 },
222 {
223 indexes: [
224 {
225 fields: [ 'name' ]
226 },
227 {
228 fields: [ 'createdAt' ]
229 },
230 {
231 fields: [ 'duration' ]
232 },
233 {
234 fields: [ 'views' ]
235 },
236 {
237 fields: [ 'likes' ]
238 },
239 {
240 fields: [ 'uuid' ]
241 },
242 {
243 fields: [ 'channelId' ]
244 }
245 ],
246 hooks: {
247 afterDestroy
248 }
249 }
250 )
251
252 const classMethods = [
253 associate,
254
255 list,
256 listAllAndSharedByAccountForOutbox,
257 listForApi,
258 listUserVideosForApi,
259 load,
260 loadByUrlAndPopulateAccount,
261 loadAndPopulateAccountAndServerAndTags,
262 loadByUUIDOrURL,
263 loadByUUID,
264 loadByUUIDAndPopulateAccountAndServerAndTags,
265 searchAndPopulateAccountAndServerAndTags
266 ]
267 const instanceMethods = [
268 createPreview,
269 createThumbnail,
270 createTorrentAndSetInfoHash,
271 getPreviewName,
272 getPreviewPath,
273 getThumbnailName,
274 getThumbnailPath,
275 getTorrentFileName,
276 getVideoFilename,
277 getVideoFilePath,
278 getOriginalFile,
279 isOwned,
280 removeFile,
281 removePreview,
282 removeThumbnail,
283 removeTorrent,
284 toActivityPubObject,
285 toFormattedJSON,
286 toFormattedDetailsJSON,
287 optimizeOriginalVideofile,
288 transcodeOriginalVideofile,
289 getOriginalFileHeight,
290 getEmbedPath,
291 getTruncatedDescription,
292 getDescriptionPath,
293 getCategoryLabel,
294 getLicenceLabel,
295 getLanguageLabel
296 ]
297 addMethodsToModel(Video, classMethods, instanceMethods)
298
299 return Video
300 }
301
302 // ------------------------------ METHODS ------------------------------
303
304 function associate (models) {
305 Video.belongsTo(models.VideoChannel, {
306 foreignKey: {
307 name: 'channelId',
308 allowNull: false
309 },
310 onDelete: 'cascade'
311 })
312
313 Video.belongsToMany(models.Tag, {
314 foreignKey: 'videoId',
315 through: models.VideoTag,
316 onDelete: 'cascade'
317 })
318
319 Video.hasMany(models.VideoAbuse, {
320 foreignKey: {
321 name: 'videoId',
322 allowNull: false
323 },
324 onDelete: 'cascade'
325 })
326
327 Video.hasMany(models.VideoFile, {
328 foreignKey: {
329 name: 'videoId',
330 allowNull: false
331 },
332 onDelete: 'cascade'
333 })
334
335 Video.hasMany(models.VideoShare, {
336 foreignKey: {
337 name: 'videoId',
338 allowNull: false
339 },
340 onDelete: 'cascade'
341 })
342
343 Video.hasMany(models.AccountVideoRate, {
344 foreignKey: {
345 name: 'videoId',
346 allowNull: false
347 },
348 onDelete: 'cascade'
349 })
350 }
351
352 function afterDestroy (video: VideoInstance) {
353 const tasks = []
354
355 tasks.push(
356 video.removeThumbnail()
357 )
358
359 if (video.isOwned()) {
360 tasks.push(
361 video.removePreview(),
362 sendDeleteVideo(video, undefined)
363 )
364
365 // Remove physical files and torrents
366 video.VideoFiles.forEach(file => {
367 tasks.push(video.removeFile(file))
368 tasks.push(video.removeTorrent(file))
369 })
370 }
371
372 return Promise.all(tasks)
373 .catch(err => {
374 logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
375 })
376 }
377
378 getOriginalFile = function (this: VideoInstance) {
379 if (Array.isArray(this.VideoFiles) === false) return undefined
380
381 // The original file is the file that have the higher resolution
382 return maxBy(this.VideoFiles, file => file.resolution)
383 }
384
385 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
386 return this.uuid + '-' + videoFile.resolution + videoFile.extname
387 }
388
389 getThumbnailName = function (this: VideoInstance) {
390 // We always have a copy of the thumbnail
391 const extension = '.jpg'
392 return this.uuid + extension
393 }
394
395 getPreviewName = function (this: VideoInstance) {
396 const extension = '.jpg'
397 return this.uuid + extension
398 }
399
400 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
401 const extension = '.torrent'
402 return this.uuid + '-' + videoFile.resolution + extension
403 }
404
405 isOwned = function (this: VideoInstance) {
406 return this.remote === false
407 }
408
409 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
410 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
411
412 return generateImageFromVideoFile(
413 this.getVideoFilePath(videoFile),
414 CONFIG.STORAGE.PREVIEWS_DIR,
415 this.getPreviewName(),
416 imageSize
417 )
418 }
419
420 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
421 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
422
423 return generateImageFromVideoFile(
424 this.getVideoFilePath(videoFile),
425 CONFIG.STORAGE.THUMBNAILS_DIR,
426 this.getThumbnailName(),
427 imageSize
428 )
429 }
430
431 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
432 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
433 }
434
435 createTorrentAndSetInfoHash = async function (this: VideoInstance, videoFile: VideoFileInstance) {
436 const options = {
437 announceList: [
438 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
439 ],
440 urlList: [
441 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
442 ]
443 }
444
445 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
446
447 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
448 logger.info('Creating torrent %s.', filePath)
449
450 await writeFilePromise(filePath, torrent)
451
452 const parsedTorrent = parseTorrent(torrent)
453 videoFile.infoHash = parsedTorrent.infoHash
454 }
455
456 getEmbedPath = function (this: VideoInstance) {
457 return '/videos/embed/' + this.uuid
458 }
459
460 getThumbnailPath = function (this: VideoInstance) {
461 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
462 }
463
464 getPreviewPath = function (this: VideoInstance) {
465 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
466 }
467
468 toFormattedJSON = function (this: VideoInstance) {
469 let serverHost
470
471 if (this.VideoChannel.Account.Server) {
472 serverHost = this.VideoChannel.Account.Server.host
473 } else {
474 // It means it's our video
475 serverHost = CONFIG.WEBSERVER.HOST
476 }
477
478 const json = {
479 id: this.id,
480 uuid: this.uuid,
481 name: this.name,
482 category: this.category,
483 categoryLabel: this.getCategoryLabel(),
484 licence: this.licence,
485 licenceLabel: this.getLicenceLabel(),
486 language: this.language,
487 languageLabel: this.getLanguageLabel(),
488 nsfw: this.nsfw,
489 description: this.getTruncatedDescription(),
490 serverHost,
491 isLocal: this.isOwned(),
492 accountName: this.VideoChannel.Account.name,
493 duration: this.duration,
494 views: this.views,
495 likes: this.likes,
496 dislikes: this.dislikes,
497 tags: map<TagInstance, string>(this.Tags, 'name'),
498 thumbnailPath: this.getThumbnailPath(),
499 previewPath: this.getPreviewPath(),
500 embedPath: this.getEmbedPath(),
501 createdAt: this.createdAt,
502 updatedAt: this.updatedAt
503 }
504
505 return json
506 }
507
508 toFormattedDetailsJSON = function (this: VideoInstance) {
509 const formattedJson = this.toFormattedJSON()
510
511 // Maybe our server is not up to date and there are new privacy settings since our version
512 let privacyLabel = VIDEO_PRIVACIES[this.privacy]
513 if (!privacyLabel) privacyLabel = 'Unknown'
514
515 const detailsJson = {
516 privacyLabel,
517 privacy: this.privacy,
518 descriptionPath: this.getDescriptionPath(),
519 channel: this.VideoChannel.toFormattedJSON(),
520 account: this.VideoChannel.Account.toFormattedJSON(),
521 files: []
522 }
523
524 // Format and sort video files
525 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
526 detailsJson.files = this.VideoFiles
527 .map(videoFile => {
528 let resolutionLabel = videoFile.resolution + 'p'
529
530 const videoFileJson = {
531 resolution: videoFile.resolution,
532 resolutionLabel,
533 magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
534 size: videoFile.size,
535 torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
536 fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
537 }
538
539 return videoFileJson
540 })
541 .sort((a, b) => {
542 if (a.resolution < b.resolution) return 1
543 if (a.resolution === b.resolution) return 0
544 return -1
545 })
546
547 return Object.assign(formattedJson, detailsJson)
548 }
549
550 toActivityPubObject = function (this: VideoInstance) {
551 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
552 if (!this.Tags) this.Tags = []
553
554 const tag = this.Tags.map(t => ({
555 type: 'Hashtag' as 'Hashtag',
556 name: t.name
557 }))
558
559 let language
560 if (this.language) {
561 language = {
562 identifier: this.language + '',
563 name: this.getLanguageLabel()
564 }
565 }
566
567 let likesObject
568 let dislikesObject
569
570 if (Array.isArray(this.AccountVideoRates)) {
571 const likes: string[] = []
572 const dislikes: string[] = []
573
574 for (const rate of this.AccountVideoRates) {
575 if (rate.type === 'like') {
576 likes.push(rate.Account.url)
577 } else if (rate.type === 'dislike') {
578 dislikes.push(rate.Account.url)
579 }
580 }
581
582 likesObject = activityPubCollection(likes)
583 dislikesObject = activityPubCollection(dislikes)
584 }
585
586 let sharesObject
587 if (Array.isArray(this.VideoShares)) {
588 const shares: string[] = []
589
590 for (const videoShare of this.VideoShares) {
591 const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Account)
592 shares.push(shareUrl)
593 }
594
595 sharesObject = activityPubCollection(shares)
596 }
597
598 const url = []
599 for (const file of this.VideoFiles) {
600 url.push({
601 type: 'Link',
602 mimeType: 'video/' + file.extname.replace('.', ''),
603 url: getVideoFileUrl(this, file, baseUrlHttp),
604 width: file.resolution,
605 size: file.size
606 })
607
608 url.push({
609 type: 'Link',
610 mimeType: 'application/x-bittorrent',
611 url: getTorrentUrl(this, file, baseUrlHttp),
612 width: file.resolution
613 })
614
615 url.push({
616 type: 'Link',
617 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
618 url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
619 width: file.resolution
620 })
621 }
622
623 // Add video url too
624 url.push({
625 type: 'Link',
626 mimeType: 'text/html',
627 url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
628 })
629
630 const videoObject: VideoTorrentObject = {
631 type: 'Video' as 'Video',
632 id: this.url,
633 name: this.name,
634 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
635 duration: 'PT' + this.duration + 'S',
636 uuid: this.uuid,
637 tag,
638 category: {
639 identifier: this.category + '',
640 name: this.getCategoryLabel()
641 },
642 licence: {
643 identifier: this.licence + '',
644 name: this.getLicenceLabel()
645 },
646 language,
647 views: this.views,
648 nsfw: this.nsfw,
649 published: this.createdAt.toISOString(),
650 updated: this.updatedAt.toISOString(),
651 mediaType: 'text/markdown',
652 content: this.getTruncatedDescription(),
653 icon: {
654 type: 'Image',
655 url: getThumbnailUrl(this, baseUrlHttp),
656 mediaType: 'image/jpeg',
657 width: THUMBNAILS_SIZE.width,
658 height: THUMBNAILS_SIZE.height
659 },
660 url,
661 likes: likesObject,
662 dislikes: dislikesObject,
663 shares: sharesObject
664 }
665
666 return videoObject
667 }
668
669 getTruncatedDescription = function (this: VideoInstance) {
670 if (!this.description) return null
671
672 const options = {
673 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
674 }
675
676 return truncate(this.description, options)
677 }
678
679 optimizeOriginalVideofile = async function (this: VideoInstance) {
680 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
681 const newExtname = '.mp4'
682 const inputVideoFile = this.getOriginalFile()
683 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
684 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
685
686 const transcodeOptions = {
687 inputPath: videoInputPath,
688 outputPath: videoOutputPath
689 }
690
691 try {
692 // Could be very long!
693 await transcode(transcodeOptions)
694
695 await unlinkPromise(videoInputPath)
696
697 // Important to do this before getVideoFilename() to take in account the new file extension
698 inputVideoFile.set('extname', newExtname)
699
700 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
701 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
702
703 inputVideoFile.set('size', stats.size)
704
705 await this.createTorrentAndSetInfoHash(inputVideoFile)
706 await inputVideoFile.save()
707
708 } catch (err) {
709 // Auto destruction...
710 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
711
712 throw err
713 }
714 }
715
716 transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
717 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
718 const extname = '.mp4'
719
720 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
721 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
722
723 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
724 resolution,
725 extname,
726 size: 0,
727 videoId: this.id
728 })
729 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
730
731 const transcodeOptions = {
732 inputPath: videoInputPath,
733 outputPath: videoOutputPath,
734 resolution
735 }
736
737 await transcode(transcodeOptions)
738
739 const stats = await statPromise(videoOutputPath)
740
741 newVideoFile.set('size', stats.size)
742
743 await this.createTorrentAndSetInfoHash(newVideoFile)
744
745 await newVideoFile.save()
746
747 this.VideoFiles.push(newVideoFile)
748 }
749
750 getOriginalFileHeight = function (this: VideoInstance) {
751 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
752
753 return getVideoFileHeight(originalFilePath)
754 }
755
756 getDescriptionPath = function (this: VideoInstance) {
757 return `/api/${API_VERSION}/videos/${this.uuid}/description`
758 }
759
760 getCategoryLabel = function (this: VideoInstance) {
761 let categoryLabel = VIDEO_CATEGORIES[this.category]
762 if (!categoryLabel) categoryLabel = 'Misc'
763
764 return categoryLabel
765 }
766
767 getLicenceLabel = function (this: VideoInstance) {
768 let licenceLabel = VIDEO_LICENCES[this.licence]
769 if (!licenceLabel) licenceLabel = 'Unknown'
770
771 return licenceLabel
772 }
773
774 getLanguageLabel = function (this: VideoInstance) {
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 }