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