]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame_incremental - server/models/video/video.ts
Add beautiful loading bar
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
... / ...
CommitLineData
1import * as Bluebird from 'bluebird'
2import { map, maxBy, truncate } from 'lodash'
3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent'
5import { join } from 'path'
6import * as Sequelize from 'sequelize'
7import { VideoPrivacy, VideoResolution } from '../../../shared'
8import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
9import { activityPubCollection } from '../../helpers/activitypub'
10import { createTorrentPromise, renamePromise, statPromise, unlinkPromise, writeFilePromise } from '../../helpers/core-utils'
11import { isVideoCategoryValid, isVideoLanguageValid, isVideoPrivacyValid } from '../../helpers/custom-validators/videos'
12import { generateImageFromVideoFile, getVideoFileHeight, transcode } from '../../helpers/ffmpeg-utils'
13import {
14 isActivityPubUrlValid,
15 isVideoDescriptionValid,
16 isVideoDurationValid,
17 isVideoLicenceValid,
18 isVideoNameValid,
19 isVideoNSFWValid
20} from '../../helpers/index'
21import { logger } from '../../helpers/logger'
22import {
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'
35import { getAnnounceActivityPubUrl } from '../../lib/activitypub/url'
36import { sendDeleteVideo } from '../../lib/index'
37import { addMethodsToModel, getSort } from '../utils'
38import { TagInstance } from './tag-interface'
39import { VideoFileInstance, VideoFileModel } from './video-file-interface'
40import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface'
41
42let Video: Sequelize.Model<VideoInstance, VideoAttributes>
43let getOriginalFile: VideoMethods.GetOriginalFile
44let getVideoFilename: VideoMethods.GetVideoFilename
45let getThumbnailName: VideoMethods.GetThumbnailName
46let getThumbnailPath: VideoMethods.GetThumbnailPath
47let getPreviewName: VideoMethods.GetPreviewName
48let getPreviewPath: VideoMethods.GetPreviewPath
49let getTorrentFileName: VideoMethods.GetTorrentFileName
50let isOwned: VideoMethods.IsOwned
51let toFormattedJSON: VideoMethods.ToFormattedJSON
52let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
53let toActivityPubObject: VideoMethods.ToActivityPubObject
54let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
55let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
56let createPreview: VideoMethods.CreatePreview
57let createThumbnail: VideoMethods.CreateThumbnail
58let getVideoFilePath: VideoMethods.GetVideoFilePath
59let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
60let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
61let getEmbedPath: VideoMethods.GetEmbedPath
62let getDescriptionPath: VideoMethods.GetDescriptionPath
63let getTruncatedDescription: VideoMethods.GetTruncatedDescription
64let getCategoryLabel: VideoMethods.GetCategoryLabel
65let getLicenceLabel: VideoMethods.GetLicenceLabel
66let getLanguageLabel: VideoMethods.GetLanguageLabel
67
68let list: VideoMethods.List
69let listForApi: VideoMethods.ListForApi
70let listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox
71let listUserVideosForApi: VideoMethods.ListUserVideosForApi
72let load: VideoMethods.Load
73let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount
74let loadByUUID: VideoMethods.LoadByUUID
75let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
76let loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags
77let loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags
78let searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags
79let removeThumbnail: VideoMethods.RemoveThumbnail
80let removePreview: VideoMethods.RemovePreview
81let removeFile: VideoMethods.RemoveFile
82let removeTorrent: VideoMethods.RemoveTorrent
83
84export 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
304function 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
352function 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
378getOriginalFile = 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
385getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
386 return this.uuid + '-' + videoFile.resolution + videoFile.extname
387}
388
389getThumbnailName = function (this: VideoInstance) {
390 // We always have a copy of the thumbnail
391 const extension = '.jpg'
392 return this.uuid + extension
393}
394
395getPreviewName = function (this: VideoInstance) {
396 const extension = '.jpg'
397 return this.uuid + extension
398}
399
400getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
401 const extension = '.torrent'
402 return this.uuid + '-' + videoFile.resolution + extension
403}
404
405isOwned = function (this: VideoInstance) {
406 return this.remote === false
407}
408
409createPreview = 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
420createThumbnail = 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
431getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
432 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
433}
434
435createTorrentAndSetInfoHash = 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
456getEmbedPath = function (this: VideoInstance) {
457 return '/videos/embed/' + this.uuid
458}
459
460getThumbnailPath = function (this: VideoInstance) {
461 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
462}
463
464getPreviewPath = function (this: VideoInstance) {
465 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
466}
467
468toFormattedJSON = 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
508toFormattedDetailsJSON = 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
550toActivityPubObject = 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 category
568 if (this.category) {
569 category = {
570 identifier: this.category + '',
571 name: this.getCategoryLabel()
572 }
573 }
574
575 let licence
576 if (this.licence) {
577 licence = {
578 identifier: this.licence + '',
579 name: this.getLicenceLabel()
580 }
581 }
582
583 let likesObject
584 let dislikesObject
585
586 if (Array.isArray(this.AccountVideoRates)) {
587 const likes: string[] = []
588 const dislikes: string[] = []
589
590 for (const rate of this.AccountVideoRates) {
591 if (rate.type === 'like') {
592 likes.push(rate.Account.url)
593 } else if (rate.type === 'dislike') {
594 dislikes.push(rate.Account.url)
595 }
596 }
597
598 likesObject = activityPubCollection(likes)
599 dislikesObject = activityPubCollection(dislikes)
600 }
601
602 let sharesObject
603 if (Array.isArray(this.VideoShares)) {
604 const shares: string[] = []
605
606 for (const videoShare of this.VideoShares) {
607 const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Account)
608 shares.push(shareUrl)
609 }
610
611 sharesObject = activityPubCollection(shares)
612 }
613
614 const url = []
615 for (const file of this.VideoFiles) {
616 url.push({
617 type: 'Link',
618 mimeType: 'video/' + file.extname.replace('.', ''),
619 url: getVideoFileUrl(this, file, baseUrlHttp),
620 width: file.resolution,
621 size: file.size
622 })
623
624 url.push({
625 type: 'Link',
626 mimeType: 'application/x-bittorrent',
627 url: getTorrentUrl(this, file, baseUrlHttp),
628 width: file.resolution
629 })
630
631 url.push({
632 type: 'Link',
633 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
634 url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
635 width: file.resolution
636 })
637 }
638
639 // Add video url too
640 url.push({
641 type: 'Link',
642 mimeType: 'text/html',
643 url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
644 })
645
646 const videoObject: VideoTorrentObject = {
647 type: 'Video' as 'Video',
648 id: this.url,
649 name: this.name,
650 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
651 duration: 'PT' + this.duration + 'S',
652 uuid: this.uuid,
653 tag,
654 category,
655 licence,
656 language,
657 views: this.views,
658 nsfw: this.nsfw,
659 published: this.createdAt.toISOString(),
660 updated: this.updatedAt.toISOString(),
661 mediaType: 'text/markdown',
662 content: this.getTruncatedDescription(),
663 icon: {
664 type: 'Image',
665 url: getThumbnailUrl(this, baseUrlHttp),
666 mediaType: 'image/jpeg',
667 width: THUMBNAILS_SIZE.width,
668 height: THUMBNAILS_SIZE.height
669 },
670 url,
671 likes: likesObject,
672 dislikes: dislikesObject,
673 shares: sharesObject
674 }
675
676 return videoObject
677}
678
679getTruncatedDescription = function (this: VideoInstance) {
680 if (!this.description) return null
681
682 const options = {
683 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
684 }
685
686 return truncate(this.description, options)
687}
688
689optimizeOriginalVideofile = async function (this: VideoInstance) {
690 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
691 const newExtname = '.mp4'
692 const inputVideoFile = this.getOriginalFile()
693 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
694 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
695
696 const transcodeOptions = {
697 inputPath: videoInputPath,
698 outputPath: videoOutputPath
699 }
700
701 try {
702 // Could be very long!
703 await transcode(transcodeOptions)
704
705 await unlinkPromise(videoInputPath)
706
707 // Important to do this before getVideoFilename() to take in account the new file extension
708 inputVideoFile.set('extname', newExtname)
709
710 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
711 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
712
713 inputVideoFile.set('size', stats.size)
714
715 await this.createTorrentAndSetInfoHash(inputVideoFile)
716 await inputVideoFile.save()
717
718 } catch (err) {
719 // Auto destruction...
720 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
721
722 throw err
723 }
724}
725
726transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
727 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
728 const extname = '.mp4'
729
730 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
731 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
732
733 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
734 resolution,
735 extname,
736 size: 0,
737 videoId: this.id
738 })
739 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
740
741 const transcodeOptions = {
742 inputPath: videoInputPath,
743 outputPath: videoOutputPath,
744 resolution
745 }
746
747 await transcode(transcodeOptions)
748
749 const stats = await statPromise(videoOutputPath)
750
751 newVideoFile.set('size', stats.size)
752
753 await this.createTorrentAndSetInfoHash(newVideoFile)
754
755 await newVideoFile.save()
756
757 this.VideoFiles.push(newVideoFile)
758}
759
760getOriginalFileHeight = function (this: VideoInstance) {
761 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
762
763 return getVideoFileHeight(originalFilePath)
764}
765
766getDescriptionPath = function (this: VideoInstance) {
767 return `/api/${API_VERSION}/videos/${this.uuid}/description`
768}
769
770getCategoryLabel = function (this: VideoInstance) {
771 let categoryLabel = VIDEO_CATEGORIES[this.category]
772 if (!categoryLabel) categoryLabel = 'Misc'
773
774 return categoryLabel
775}
776
777getLicenceLabel = function (this: VideoInstance) {
778 let licenceLabel = VIDEO_LICENCES[this.licence]
779 if (!licenceLabel) licenceLabel = 'Unknown'
780
781 return licenceLabel
782}
783
784getLanguageLabel = function (this: VideoInstance) {
785 let languageLabel = VIDEO_LANGUAGES[this.language]
786 if (!languageLabel) languageLabel = 'Unknown'
787
788 return languageLabel
789}
790
791removeThumbnail = function (this: VideoInstance) {
792 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
793 return unlinkPromise(thumbnailPath)
794}
795
796removePreview = function (this: VideoInstance) {
797 // Same name than video thumbnail
798 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
799}
800
801removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
802 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
803 return unlinkPromise(filePath)
804}
805
806removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
807 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
808 return unlinkPromise(torrentPath)
809}
810
811// ------------------------------ STATICS ------------------------------
812
813list = function () {
814 const query = {
815 include: [ Video['sequelize'].models.VideoFile ]
816 }
817
818 return Video.findAll(query)
819}
820
821listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) {
822 function getRawQuery (select: string) {
823 const queryVideo = 'SELECT ' + select + ' FROM "Videos" AS "Video" ' +
824 'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
825 'WHERE "VideoChannel"."accountId" = ' + accountId
826 const queryVideoShare = 'SELECT ' + select + ' FROM "VideoShares" AS "VideoShare" ' +
827 'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
828 'WHERE "VideoShare"."accountId" = ' + accountId
829
830 let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})`
831
832 return rawQuery
833 }
834
835 const rawQuery = getRawQuery('"Video"."id"')
836 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
837
838 const query = {
839 distinct: true,
840 offset: start,
841 limit: count,
842 order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
843 where: {
844 id: {
845 [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
846 }
847 },
848 include: [
849 {
850 model: Video['sequelize'].models.VideoShare,
851 required: false,
852 where: {
853 [Sequelize.Op.and]: [
854 {
855 id: {
856 [Sequelize.Op.not]: null
857 }
858 },
859 {
860 accountId
861 }
862 ]
863 },
864 include: [ Video['sequelize'].models.Account ]
865 },
866 {
867 model: Video['sequelize'].models.VideoChannel,
868 required: true,
869 include: [
870 {
871 model: Video['sequelize'].models.Account,
872 required: true
873 }
874 ]
875 },
876 {
877 model: Video['sequelize'].models.AccountVideoRate,
878 include: [ Video['sequelize'].models.Account ]
879 },
880 Video['sequelize'].models.VideoFile,
881 Video['sequelize'].models.Tag
882 ]
883 }
884
885 return Bluebird.all([
886 Video.findAll(query),
887 Video['sequelize'].query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
888 ]).then(([ rows, totals ]) => {
889 // totals: totalVideos + totalVideoShares
890 let totalVideos = 0
891 let totalVideoShares = 0
892 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
893 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
894
895 const total = totalVideos + totalVideoShares
896 return {
897 data: rows,
898 total: total
899 }
900 })
901}
902
903listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) {
904 const query = {
905 distinct: true,
906 offset: start,
907 limit: count,
908 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
909 include: [
910 {
911 model: Video['sequelize'].models.VideoChannel,
912 required: true,
913 include: [
914 {
915 model: Video['sequelize'].models.Account,
916 where: {
917 userId
918 },
919 required: true
920 }
921 ]
922 },
923 Video['sequelize'].models.Tag
924 ]
925 }
926
927 return Video.findAndCountAll(query).then(({ rows, count }) => {
928 return {
929 data: rows,
930 total: count
931 }
932 })
933}
934
935listForApi = function (start: number, count: number, sort: string) {
936 const query = {
937 distinct: true,
938 offset: start,
939 limit: count,
940 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
941 include: [
942 {
943 model: Video['sequelize'].models.VideoChannel,
944 required: true,
945 include: [
946 {
947 model: Video['sequelize'].models.Account,
948 required: true,
949 include: [
950 {
951 model: Video['sequelize'].models.Server,
952 required: false
953 }
954 ]
955 }
956 ]
957 },
958 Video['sequelize'].models.Tag
959 ],
960 where: createBaseVideosWhere()
961 }
962
963 return Video.findAndCountAll(query).then(({ rows, count }) => {
964 return {
965 data: rows,
966 total: count
967 }
968 })
969}
970
971load = function (id: number) {
972 return Video.findById(id)
973}
974
975loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
976 const query: Sequelize.FindOptions<VideoAttributes> = {
977 where: {
978 uuid
979 },
980 include: [ Video['sequelize'].models.VideoFile ]
981 }
982
983 if (t !== undefined) query.transaction = t
984
985 return Video.findOne(query)
986}
987
988loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) {
989 const query: Sequelize.FindOptions<VideoAttributes> = {
990 where: {
991 url
992 },
993 include: [
994 Video['sequelize'].models.VideoFile,
995 {
996 model: Video['sequelize'].models.VideoChannel,
997 include: [ Video['sequelize'].models.Account ]
998 }
999 ]
1000 }
1001
1002 if (t !== undefined) query.transaction = t
1003
1004 return Video.findOne(query)
1005}
1006
1007loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) {
1008 const query: Sequelize.FindOptions<VideoAttributes> = {
1009 where: {
1010 [Sequelize.Op.or]: [
1011 { uuid },
1012 { url }
1013 ]
1014 },
1015 include: [ Video['sequelize'].models.VideoFile ]
1016 }
1017
1018 if (t !== undefined) query.transaction = t
1019
1020 return Video.findOne(query)
1021}
1022
1023loadAndPopulateAccountAndServerAndTags = function (id: number) {
1024 const options = {
1025 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
1026 include: [
1027 {
1028 model: Video['sequelize'].models.VideoChannel,
1029 include: [
1030 {
1031 model: Video['sequelize'].models.Account,
1032 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1033 }
1034 ]
1035 },
1036 {
1037 model: Video['sequelize'].models.AccountVideoRate,
1038 include: [ Video['sequelize'].models.Account ]
1039 },
1040 {
1041 model: Video['sequelize'].models.VideoShare,
1042 include: [ Video['sequelize'].models.Account ]
1043 },
1044 Video['sequelize'].models.Tag,
1045 Video['sequelize'].models.VideoFile
1046 ]
1047 }
1048
1049 return Video.findById(id, options)
1050}
1051
1052loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
1053 const options = {
1054 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
1055 where: {
1056 uuid
1057 },
1058 include: [
1059 {
1060 model: Video['sequelize'].models.VideoChannel,
1061 include: [
1062 {
1063 model: Video['sequelize'].models.Account,
1064 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1065 }
1066 ]
1067 },
1068 {
1069 model: Video['sequelize'].models.AccountVideoRate,
1070 include: [ Video['sequelize'].models.Account ]
1071 },
1072 {
1073 model: Video['sequelize'].models.VideoShare,
1074 include: [ Video['sequelize'].models.Account ]
1075 },
1076 Video['sequelize'].models.Tag,
1077 Video['sequelize'].models.VideoFile
1078 ]
1079 }
1080
1081 return Video.findOne(options)
1082}
1083
1084searchAndPopulateAccountAndServerAndTags = function (value: string, start: number, count: number, sort: string) {
1085 const serverInclude: Sequelize.IncludeOptions = {
1086 model: Video['sequelize'].models.Server,
1087 required: false
1088 }
1089
1090 const accountInclude: Sequelize.IncludeOptions = {
1091 model: Video['sequelize'].models.Account,
1092 include: [ serverInclude ]
1093 }
1094
1095 const videoChannelInclude: Sequelize.IncludeOptions = {
1096 model: Video['sequelize'].models.VideoChannel,
1097 include: [ accountInclude ],
1098 required: true
1099 }
1100
1101 const tagInclude: Sequelize.IncludeOptions = {
1102 model: Video['sequelize'].models.Tag
1103 }
1104
1105 const query: Sequelize.FindOptions<VideoAttributes> = {
1106 distinct: true,
1107 where: createBaseVideosWhere(),
1108 offset: start,
1109 limit: count,
1110 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
1111 }
1112
1113 // TODO: search on tags too
1114 // const escapedValue = Video['sequelize'].escape('%' + value + '%')
1115 // query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
1116 // `(SELECT "VideoTags"."videoId"
1117 // FROM "Tags"
1118 // INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
1119 // WHERE name ILIKE ${escapedValue}
1120 // )`
1121 // )
1122
1123 // TODO: search on account too
1124 // accountInclude.where = {
1125 // name: {
1126 // [Sequelize.Op.iLike]: '%' + value + '%'
1127 // }
1128 // }
1129 query.where['name'] = {
1130 [Sequelize.Op.iLike]: '%' + value + '%'
1131 }
1132
1133 query.include = [
1134 videoChannelInclude, tagInclude
1135 ]
1136
1137 return Video.findAndCountAll(query).then(({ rows, count }) => {
1138 return {
1139 data: rows,
1140 total: count
1141 }
1142 })
1143}
1144
1145// ---------------------------------------------------------------------------
1146
1147function createBaseVideosWhere () {
1148 return {
1149 id: {
1150 [Sequelize.Op.notIn]: Video['sequelize'].literal(
1151 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1152 )
1153 },
1154 privacy: VideoPrivacy.PUBLIC
1155 }
1156}
1157
1158function getBaseUrls (video: VideoInstance) {
1159 let baseUrlHttp
1160 let baseUrlWs
1161
1162 if (video.isOwned()) {
1163 baseUrlHttp = CONFIG.WEBSERVER.URL
1164 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1165 } else {
1166 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
1167 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
1168 }
1169
1170 return { baseUrlHttp, baseUrlWs }
1171}
1172
1173function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1174 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1175}
1176
1177function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1178 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1179}
1180
1181function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1182 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1183}
1184
1185function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1186 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1187 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1188 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1189
1190 const magnetHash = {
1191 xs,
1192 announce,
1193 urlList,
1194 infoHash: videoFile.infoHash,
1195 name: video.name
1196 }
1197
1198 return magnetUtil.encode(magnetHash)
1199}