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