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