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