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