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