]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Rename Pod -> Server
[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.VideoChannel, {
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 include: [
829 {
830 model: Video['sequelize'].models.Account,
831 include: [
832 {
833 model: Video['sequelize'].models.Server,
834 required: false
835 }
836 ]
837 }
838 ]
839 },
840 Video['sequelize'].models.Tag
841 ],
842 where: createBaseVideosWhere()
843 }
844
845 return Video.findAndCountAll(query).then(({ rows, count }) => {
846 return {
847 data: rows,
848 total: count
849 }
850 })
851 }
852
853 loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
854 const query: Sequelize.FindOptions<VideoAttributes> = {
855 where: {
856 uuid
857 },
858 include: [
859 {
860 model: Video['sequelize'].models.VideoFile
861 },
862 {
863 model: Video['sequelize'].models.VideoChannel,
864 include: [
865 {
866 model: Video['sequelize'].models.Account,
867 include: [
868 {
869 model: Video['sequelize'].models.Server,
870 required: true,
871 where: {
872 host: fromHost
873 }
874 }
875 ]
876 }
877 ]
878 }
879 ]
880 }
881
882 if (t !== undefined) query.transaction = t
883
884 return Video.findOne(query)
885 }
886
887 listOwnedAndPopulateAccountAndTags = function () {
888 const query = {
889 where: {
890 remote: false
891 },
892 include: [
893 Video['sequelize'].models.VideoFile,
894 {
895 model: Video['sequelize'].models.VideoChannel,
896 include: [ Video['sequelize'].models.Account ]
897 },
898 Video['sequelize'].models.Tag
899 ]
900 }
901
902 return Video.findAll(query)
903 }
904
905 listOwnedByAccount = function (account: string) {
906 const query = {
907 where: {
908 remote: false
909 },
910 include: [
911 {
912 model: Video['sequelize'].models.VideoFile
913 },
914 {
915 model: Video['sequelize'].models.VideoChannel,
916 include: [
917 {
918 model: Video['sequelize'].models.Account,
919 where: {
920 name: account
921 }
922 }
923 ]
924 }
925 ]
926 }
927
928 return Video.findAll(query)
929 }
930
931 load = function (id: number) {
932 return Video.findById(id)
933 }
934
935 loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
936 const query: Sequelize.FindOptions<VideoAttributes> = {
937 where: {
938 uuid
939 },
940 include: [ Video['sequelize'].models.VideoFile ]
941 }
942
943 if (t !== undefined) query.transaction = t
944
945 return Video.findOne(query)
946 }
947
948 loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) {
949 const query: Sequelize.FindOptions<VideoAttributes> = {
950 where: {
951 [Sequelize.Op.or]: [
952 { uuid },
953 { url }
954 ]
955 },
956 include: [ Video['sequelize'].models.VideoFile ]
957 }
958
959 if (t !== undefined) query.transaction = t
960
961 return Video.findOne(query)
962 }
963
964 loadLocalVideoByUUID = function (uuid: string, t?: Sequelize.Transaction) {
965 const query: Sequelize.FindOptions<VideoAttributes> = {
966 where: {
967 uuid,
968 remote: false
969 },
970 include: [ Video['sequelize'].models.VideoFile ]
971 }
972
973 if (t !== undefined) query.transaction = t
974
975 return Video.findOne(query)
976 }
977
978 loadAndPopulateAccount = function (id: number) {
979 const options = {
980 include: [
981 Video['sequelize'].models.VideoFile,
982 {
983 model: Video['sequelize'].models.VideoChannel,
984 include: [ Video['sequelize'].models.Account ]
985 }
986 ]
987 }
988
989 return Video.findById(id, options)
990 }
991
992 loadAndPopulateAccountAndServerAndTags = function (id: number) {
993 const options = {
994 include: [
995 {
996 model: Video['sequelize'].models.VideoChannel,
997 include: [
998 {
999 model: Video['sequelize'].models.Account,
1000 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1001 }
1002 ]
1003 },
1004 Video['sequelize'].models.Tag,
1005 Video['sequelize'].models.VideoFile
1006 ]
1007 }
1008
1009 return Video.findById(id, options)
1010 }
1011
1012 loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
1013 const options = {
1014 where: {
1015 uuid
1016 },
1017 include: [
1018 {
1019 model: Video['sequelize'].models.VideoChannel,
1020 include: [
1021 {
1022 model: Video['sequelize'].models.Account,
1023 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1024 }
1025 ]
1026 },
1027 Video['sequelize'].models.Tag,
1028 Video['sequelize'].models.VideoFile
1029 ]
1030 }
1031
1032 return Video.findOne(options)
1033 }
1034
1035 searchAndPopulateAccountAndServerAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
1036 const serverInclude: Sequelize.IncludeOptions = {
1037 model: Video['sequelize'].models.Server,
1038 required: false
1039 }
1040
1041 const accountInclude: Sequelize.IncludeOptions = {
1042 model: Video['sequelize'].models.Account,
1043 include: [ serverInclude ]
1044 }
1045
1046 const videoChannelInclude: Sequelize.IncludeOptions = {
1047 model: Video['sequelize'].models.VideoChannel,
1048 include: [ accountInclude ],
1049 required: true
1050 }
1051
1052 const tagInclude: Sequelize.IncludeOptions = {
1053 model: Video['sequelize'].models.Tag
1054 }
1055
1056 const query: Sequelize.FindOptions<VideoAttributes> = {
1057 distinct: true,
1058 where: createBaseVideosWhere(),
1059 offset: start,
1060 limit: count,
1061 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
1062 }
1063
1064 if (field === 'tags') {
1065 const escapedValue = Video['sequelize'].escape('%' + value + '%')
1066 query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
1067 `(SELECT "VideoTags"."videoId"
1068 FROM "Tags"
1069 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
1070 WHERE name ILIKE ${escapedValue}
1071 )`
1072 )
1073 } else if (field === 'host') {
1074 // FIXME: Include our server? (not stored in the database)
1075 serverInclude.where = {
1076 host: {
1077 [Sequelize.Op.iLike]: '%' + value + '%'
1078 }
1079 }
1080 serverInclude.required = true
1081 } else if (field === 'account') {
1082 accountInclude.where = {
1083 name: {
1084 [Sequelize.Op.iLike]: '%' + value + '%'
1085 }
1086 }
1087 } else {
1088 query.where[field] = {
1089 [Sequelize.Op.iLike]: '%' + value + '%'
1090 }
1091 }
1092
1093 query.include = [
1094 videoChannelInclude, tagInclude
1095 ]
1096
1097 return Video.findAndCountAll(query).then(({ rows, count }) => {
1098 return {
1099 data: rows,
1100 total: count
1101 }
1102 })
1103 }
1104
1105 // ---------------------------------------------------------------------------
1106
1107 function createBaseVideosWhere () {
1108 return {
1109 id: {
1110 [Sequelize.Op.notIn]: Video['sequelize'].literal(
1111 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1112 )
1113 },
1114 privacy: VideoPrivacy.PUBLIC
1115 }
1116 }
1117
1118 function getBaseUrls (video: VideoInstance) {
1119 let baseUrlHttp
1120 let baseUrlWs
1121
1122 if (video.isOwned()) {
1123 baseUrlHttp = CONFIG.WEBSERVER.URL
1124 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1125 } else {
1126 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
1127 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
1128 }
1129
1130 return { baseUrlHttp, baseUrlWs }
1131 }
1132
1133 function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1134 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1135 }
1136
1137 function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1138 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1139 }
1140
1141 function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1142 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1143 }
1144
1145 function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1146 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1147 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1148 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1149
1150 const magnetHash = {
1151 xs,
1152 announce,
1153 urlList,
1154 infoHash: videoFile.infoHash,
1155 name: video.name
1156 }
1157
1158 return magnetUtil.encode(magnetHash)
1159 }