]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
5fb254b2d8f43ec7acd31868dbb310fe15d917a0
[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
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: getActivityPubUrl('video', this.uuid),
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
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 }