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