]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Use sequelize scopes
[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 {
8 AfterDestroy,
9 AllowNull,
10 BelongsTo,
11 BelongsToMany,
12 Column,
13 CreatedAt,
14 DataType,
15 Default,
16 ForeignKey,
17 HasMany,
18 IFindOptions,
19 Is,
20 IsInt,
21 IsUUID,
22 Min,
23 Model,
24 Scopes,
25 Table,
26 UpdatedAt
27 } from 'sequelize-typescript'
28 import { IIncludeOptions } from 'sequelize-typescript/lib/interfaces/IIncludeOptions'
29 import { VideoPrivacy, VideoResolution } from '../../../shared'
30 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
31 import { Video, VideoDetails } from '../../../shared/models/videos'
32 import {
33 activityPubCollection,
34 createTorrentPromise,
35 generateImageFromVideoFile,
36 getVideoFileHeight,
37 logger,
38 renamePromise,
39 statPromise,
40 transcode,
41 unlinkPromise,
42 writeFilePromise
43 } from '../../helpers'
44 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub'
45 import {
46 isVideoCategoryValid,
47 isVideoDescriptionValid,
48 isVideoDurationValid,
49 isVideoLanguageValid,
50 isVideoLicenceValid,
51 isVideoNameValid,
52 isVideoNSFWValid,
53 isVideoPrivacyValid
54 } from '../../helpers/custom-validators/videos'
55 import {
56 API_VERSION,
57 CONFIG,
58 CONSTRAINTS_FIELDS,
59 PREVIEWS_SIZE,
60 REMOTE_SCHEME,
61 STATIC_PATHS,
62 THUMBNAILS_SIZE,
63 VIDEO_CATEGORIES,
64 VIDEO_LANGUAGES,
65 VIDEO_LICENCES,
66 VIDEO_PRIVACIES
67 } from '../../initializers'
68 import { getAnnounceActivityPubUrl } from '../../lib/activitypub'
69 import { sendDeleteVideo } from '../../lib/index'
70 import { AccountModel } from '../account/account'
71 import { AccountVideoRateModel } from '../account/account-video-rate'
72 import { ServerModel } from '../server/server'
73 import { getSort, throwIfNotValid } from '../utils'
74 import { TagModel } from './tag'
75 import { VideoAbuseModel } from './video-abuse'
76 import { VideoChannelModel } from './video-channel'
77 import { VideoFileModel } from './video-file'
78 import { VideoShareModel } from './video-share'
79 import { VideoTagModel } from './video-tag'
80
81 enum ScopeNames {
82 NOT_IN_BLACKLIST = 'NOT_IN_BLACKLIST',
83 PUBLIC = 'PUBLIC',
84 WITH_ACCOUNT = 'WITH_ACCOUNT',
85 WITH_TAGS = 'WITH_TAGS',
86 WITH_FILES = 'WITH_FILES',
87 WITH_SHARES = 'WITH_SHARES',
88 WITH_RATES = 'WITH_RATES'
89 }
90
91 @Scopes({
92 [ScopeNames.NOT_IN_BLACKLIST]: {
93 where: {
94 id: {
95 [Sequelize.Op.notIn]: Sequelize.literal(
96 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
97 )
98 }
99 }
100 },
101 [ScopeNames.PUBLIC]: {
102 where: {
103 privacy: VideoPrivacy.PUBLIC
104 }
105 },
106 [ScopeNames.WITH_ACCOUNT]: {
107 include: [
108 {
109 model: () => VideoChannelModel,
110 required: true,
111 include: [
112 {
113 model: () => AccountModel,
114 required: true,
115 include: [
116 {
117 model: () => ServerModel,
118 required: false
119 }
120 ]
121 }
122 ]
123 }
124 ]
125 },
126 [ScopeNames.WITH_TAGS]: {
127 include: [ () => TagModel ]
128 },
129 [ScopeNames.WITH_FILES]: {
130 include: [
131 {
132 model: () => VideoFileModel,
133 required: true
134 }
135 ]
136 },
137 [ScopeNames.WITH_SHARES]: {
138 include: [
139 {
140 model: () => VideoShareModel,
141 include: [ () => AccountModel ]
142 }
143 ]
144 },
145 [ScopeNames.WITH_RATES]: {
146 include: [
147 {
148 model: () => AccountVideoRateModel,
149 include: [ () => AccountModel ]
150 }
151 ]
152 }
153 })
154 @Table({
155 tableName: 'video',
156 indexes: [
157 {
158 fields: [ 'name' ]
159 },
160 {
161 fields: [ 'createdAt' ]
162 },
163 {
164 fields: [ 'duration' ]
165 },
166 {
167 fields: [ 'views' ]
168 },
169 {
170 fields: [ 'likes' ]
171 },
172 {
173 fields: [ 'uuid' ]
174 },
175 {
176 fields: [ 'channelId' ]
177 }
178 ]
179 })
180 export class VideoModel extends Model<VideoModel> {
181
182 @AllowNull(false)
183 @Default(DataType.UUIDV4)
184 @IsUUID(4)
185 @Column(DataType.UUID)
186 uuid: string
187
188 @AllowNull(false)
189 @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
190 @Column
191 name: string
192
193 @AllowNull(true)
194 @Default(null)
195 @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category'))
196 @Column
197 category: number
198
199 @AllowNull(true)
200 @Default(null)
201 @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence'))
202 @Column
203 licence: number
204
205 @AllowNull(true)
206 @Default(null)
207 @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language'))
208 @Column
209 language: number
210
211 @AllowNull(false)
212 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
213 @Column
214 privacy: number
215
216 @AllowNull(false)
217 @Is('VideoNSFW', value => throwIfNotValid(value, isVideoNSFWValid, 'NSFW boolean'))
218 @Column
219 nsfw: boolean
220
221 @AllowNull(true)
222 @Default(null)
223 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description'))
224 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
225 description: string
226
227 @AllowNull(false)
228 @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
229 @Column
230 duration: number
231
232 @AllowNull(false)
233 @Default(0)
234 @IsInt
235 @Min(0)
236 @Column
237 views: number
238
239 @AllowNull(false)
240 @Default(0)
241 @IsInt
242 @Min(0)
243 @Column
244 likes: number
245
246 @AllowNull(false)
247 @Default(0)
248 @IsInt
249 @Min(0)
250 @Column
251 dislikes: number
252
253 @AllowNull(false)
254 @Column
255 remote: boolean
256
257 @AllowNull(false)
258 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
259 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
260 url: string
261
262 @CreatedAt
263 createdAt: Date
264
265 @UpdatedAt
266 updatedAt: Date
267
268 @ForeignKey(() => VideoChannelModel)
269 @Column
270 channelId: number
271
272 @BelongsTo(() => VideoChannelModel, {
273 foreignKey: {
274 allowNull: false
275 },
276 onDelete: 'cascade'
277 })
278 VideoChannel: VideoChannelModel
279
280 @BelongsToMany(() => TagModel, {
281 foreignKey: 'videoId',
282 through: () => VideoTagModel,
283 onDelete: 'CASCADE'
284 })
285 Tags: TagModel[]
286
287 @HasMany(() => VideoAbuseModel, {
288 foreignKey: {
289 name: 'videoId',
290 allowNull: false
291 },
292 onDelete: 'cascade'
293 })
294 VideoAbuses: VideoAbuseModel[]
295
296 @HasMany(() => VideoFileModel, {
297 foreignKey: {
298 name: 'videoId',
299 allowNull: false
300 },
301 onDelete: 'cascade'
302 })
303 VideoFiles: VideoFileModel[]
304
305 @HasMany(() => VideoShareModel, {
306 foreignKey: {
307 name: 'videoId',
308 allowNull: false
309 },
310 onDelete: 'cascade'
311 })
312 VideoShares: VideoShareModel[]
313
314 @HasMany(() => AccountVideoRateModel, {
315 foreignKey: {
316 name: 'videoId',
317 allowNull: false
318 },
319 onDelete: 'cascade'
320 })
321 AccountVideoRates: AccountVideoRateModel[]
322
323 @AfterDestroy
324 static removeFilesAndSendDelete (instance: VideoModel) {
325 const tasks = []
326
327 tasks.push(
328 instance.removeThumbnail()
329 )
330
331 if (instance.isOwned()) {
332 tasks.push(
333 instance.removePreview(),
334 sendDeleteVideo(instance, undefined)
335 )
336
337 // Remove physical files and torrents
338 instance.VideoFiles.forEach(file => {
339 tasks.push(instance.removeFile(file))
340 tasks.push(instance.removeTorrent(file))
341 })
342 }
343
344 return Promise.all(tasks)
345 .catch(err => {
346 logger.error('Some errors when removing files of video %s in after destroy hook.', instance.uuid, err)
347 })
348 }
349
350 static list () {
351 return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
352 }
353
354 static listAllAndSharedByAccountForOutbox (accountId: number, start: number, count: number) {
355 function getRawQuery (select: string) {
356 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
357 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
358 'WHERE "VideoChannel"."accountId" = ' + accountId
359 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
360 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
361 'WHERE "VideoShare"."accountId" = ' + accountId
362
363 return `(${queryVideo}) UNION (${queryVideoShare})`
364 }
365
366 const rawQuery = getRawQuery('"Video"."id"')
367 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
368
369 const query = {
370 distinct: true,
371 offset: start,
372 limit: count,
373 order: [ getSort('createdAt'), [ 'Tags', 'name', 'ASC' ] ],
374 where: {
375 id: {
376 [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
377 }
378 },
379 include: [
380 {
381 model: VideoShareModel,
382 required: false,
383 where: {
384 [Sequelize.Op.and]: [
385 {
386 id: {
387 [Sequelize.Op.not]: null
388 }
389 },
390 {
391 accountId
392 }
393 ]
394 },
395 include: [ AccountModel ]
396 },
397 {
398 model: VideoChannelModel,
399 required: true,
400 include: [
401 {
402 model: AccountModel,
403 required: true
404 }
405 ]
406 },
407 {
408 model: AccountVideoRateModel,
409 include: [ AccountModel ]
410 },
411 VideoFileModel,
412 TagModel
413 ]
414 }
415
416 return Bluebird.all([
417 // FIXME: typing issue
418 VideoModel.findAll(query as any),
419 VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
420 ]).then(([ rows, totals ]) => {
421 // totals: totalVideos + totalVideoShares
422 let totalVideos = 0
423 let totalVideoShares = 0
424 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
425 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
426
427 const total = totalVideos + totalVideoShares
428 return {
429 data: rows,
430 total: total
431 }
432 })
433 }
434
435 static listUserVideosForApi (userId: number, start: number, count: number, sort: string) {
436 const query = {
437 offset: start,
438 limit: count,
439 order: [ getSort(sort) ],
440 include: [
441 {
442 model: VideoChannelModel,
443 required: true,
444 include: [
445 {
446 model: AccountModel,
447 where: {
448 userId
449 },
450 required: true
451 }
452 ]
453 }
454 ]
455 }
456
457 return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
458 return {
459 data: rows,
460 total: count
461 }
462 })
463 }
464
465 static listForApi (start: number, count: number, sort: string) {
466 const query = {
467 offset: start,
468 limit: count,
469 order: [ getSort(sort) ]
470 }
471
472 return VideoModel.scope([ ScopeNames.NOT_IN_BLACKLIST, ScopeNames.PUBLIC, ScopeNames.WITH_ACCOUNT ])
473 .findAndCountAll(query)
474 .then(({ rows, count }) => {
475 return {
476 data: rows,
477 total: count
478 }
479 })
480 }
481
482 static load (id: number) {
483 return VideoModel.findById(id)
484 }
485
486 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
487 const query: IFindOptions<VideoModel> = {
488 where: {
489 url
490 }
491 }
492
493 if (t !== undefined) query.transaction = t
494
495 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_FILES ]).findOne(query)
496 }
497
498 static loadByUUIDOrURL (uuid: string, url: string, t?: Sequelize.Transaction) {
499 const query: IFindOptions<VideoModel> = {
500 where: {
501 [Sequelize.Op.or]: [
502 { uuid },
503 { url }
504 ]
505 }
506 }
507
508 if (t !== undefined) query.transaction = t
509
510 return VideoModel.scope(ScopeNames.WITH_FILES).findOne(query)
511 }
512
513 static loadAndPopulateAccountAndServerAndTags (id: number) {
514 const options = {
515 order: [ [ 'Tags', 'name', 'ASC' ] ]
516 }
517
518 return VideoModel
519 .scope([ ScopeNames.WITH_RATES, ScopeNames.WITH_SHARES, ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT ])
520 .findById(id, options)
521 }
522
523 static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) {
524 const options = {
525 order: [ [ 'Tags', 'name', 'ASC' ] ],
526 where: {
527 uuid
528 }
529 }
530
531 return VideoModel
532 .scope([ ScopeNames.WITH_RATES, ScopeNames.WITH_SHARES, ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT ])
533 .findOne(options)
534 }
535
536 static searchAndPopulateAccountAndServerAndTags (value: string, start: number, count: number, sort: string) {
537 const serverInclude: IIncludeOptions = {
538 model: ServerModel,
539 required: false
540 }
541
542 const accountInclude: IIncludeOptions = {
543 model: AccountModel,
544 include: [ serverInclude ]
545 }
546
547 const videoChannelInclude: IIncludeOptions = {
548 model: VideoChannelModel,
549 include: [ accountInclude ],
550 required: true
551 }
552
553 const tagInclude: IIncludeOptions = {
554 model: TagModel
555 }
556
557 const query: IFindOptions<VideoModel> = {
558 distinct: true, // Because we have tags
559 offset: start,
560 limit: count,
561 order: [ getSort(sort) ],
562 where: {}
563 }
564
565 // TODO: search on tags too
566 // const escapedValue = Video['sequelize'].escape('%' + value + '%')
567 // query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
568 // `(SELECT "VideoTags"."videoId"
569 // FROM "Tags"
570 // INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
571 // WHERE name ILIKE ${escapedValue}
572 // )`
573 // )
574
575 // TODO: search on account too
576 // accountInclude.where = {
577 // name: {
578 // [Sequelize.Op.iLike]: '%' + value + '%'
579 // }
580 // }
581 query.where['name'] = {
582 [Sequelize.Op.iLike]: '%' + value + '%'
583 }
584
585 query.include = [
586 videoChannelInclude, tagInclude
587 ]
588
589 return VideoModel.scope([ ScopeNames.NOT_IN_BLACKLIST, ScopeNames.PUBLIC ])
590 .findAndCountAll(query).then(({ rows, count }) => {
591 return {
592 data: rows,
593 total: count
594 }
595 })
596 }
597
598 getOriginalFile () {
599 if (Array.isArray(this.VideoFiles) === false) return undefined
600
601 // The original file is the file that have the higher resolution
602 return maxBy(this.VideoFiles, file => file.resolution)
603 }
604
605 getVideoFilename (videoFile: VideoFileModel) {
606 return this.uuid + '-' + videoFile.resolution + videoFile.extname
607 }
608
609 getThumbnailName () {
610 // We always have a copy of the thumbnail
611 const extension = '.jpg'
612 return this.uuid + extension
613 }
614
615 getPreviewName () {
616 const extension = '.jpg'
617 return this.uuid + extension
618 }
619
620 getTorrentFileName (videoFile: VideoFileModel) {
621 const extension = '.torrent'
622 return this.uuid + '-' + videoFile.resolution + extension
623 }
624
625 isOwned () {
626 return this.remote === false
627 }
628
629 createPreview (videoFile: VideoFileModel) {
630 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
631
632 return generateImageFromVideoFile(
633 this.getVideoFilePath(videoFile),
634 CONFIG.STORAGE.PREVIEWS_DIR,
635 this.getPreviewName(),
636 imageSize
637 )
638 }
639
640 createThumbnail (videoFile: VideoFileModel) {
641 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
642
643 return generateImageFromVideoFile(
644 this.getVideoFilePath(videoFile),
645 CONFIG.STORAGE.THUMBNAILS_DIR,
646 this.getThumbnailName(),
647 imageSize
648 )
649 }
650
651 getVideoFilePath (videoFile: VideoFileModel) {
652 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
653 }
654
655 createTorrentAndSetInfoHash = async function (videoFile: VideoFileModel) {
656 const options = {
657 announceList: [
658 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
659 ],
660 urlList: [
661 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
662 ]
663 }
664
665 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
666
667 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
668 logger.info('Creating torrent %s.', filePath)
669
670 await writeFilePromise(filePath, torrent)
671
672 const parsedTorrent = parseTorrent(torrent)
673 videoFile.infoHash = parsedTorrent.infoHash
674 }
675
676 getEmbedPath () {
677 return '/videos/embed/' + this.uuid
678 }
679
680 getThumbnailPath () {
681 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
682 }
683
684 getPreviewPath () {
685 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
686 }
687
688 toFormattedJSON () {
689 let serverHost
690
691 if (this.VideoChannel.Account.Server) {
692 serverHost = this.VideoChannel.Account.Server.host
693 } else {
694 // It means it's our video
695 serverHost = CONFIG.WEBSERVER.HOST
696 }
697
698 return {
699 id: this.id,
700 uuid: this.uuid,
701 name: this.name,
702 category: this.category,
703 categoryLabel: this.getCategoryLabel(),
704 licence: this.licence,
705 licenceLabel: this.getLicenceLabel(),
706 language: this.language,
707 languageLabel: this.getLanguageLabel(),
708 nsfw: this.nsfw,
709 description: this.getTruncatedDescription(),
710 serverHost,
711 isLocal: this.isOwned(),
712 accountName: this.VideoChannel.Account.name,
713 duration: this.duration,
714 views: this.views,
715 likes: this.likes,
716 dislikes: this.dislikes,
717 thumbnailPath: this.getThumbnailPath(),
718 previewPath: this.getPreviewPath(),
719 embedPath: this.getEmbedPath(),
720 createdAt: this.createdAt,
721 updatedAt: this.updatedAt
722 } as Video
723 }
724
725 toFormattedDetailsJSON () {
726 const formattedJson = this.toFormattedJSON()
727
728 // Maybe our server is not up to date and there are new privacy settings since our version
729 let privacyLabel = VIDEO_PRIVACIES[this.privacy]
730 if (!privacyLabel) privacyLabel = 'Unknown'
731
732 const detailsJson = {
733 privacyLabel,
734 privacy: this.privacy,
735 descriptionPath: this.getDescriptionPath(),
736 channel: this.VideoChannel.toFormattedJSON(),
737 account: this.VideoChannel.Account.toFormattedJSON(),
738 tags: map<TagModel, string>(this.Tags, 'name'),
739 files: []
740 }
741
742 // Format and sort video files
743 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
744 detailsJson.files = this.VideoFiles
745 .map(videoFile => {
746 let resolutionLabel = videoFile.resolution + 'p'
747
748 return {
749 resolution: videoFile.resolution,
750 resolutionLabel,
751 magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
752 size: videoFile.size,
753 torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
754 fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
755 }
756 })
757 .sort((a, b) => {
758 if (a.resolution < b.resolution) return 1
759 if (a.resolution === b.resolution) return 0
760 return -1
761 })
762
763 return Object.assign(formattedJson, detailsJson) as VideoDetails
764 }
765
766 toActivityPubObject (): VideoTorrentObject {
767 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
768 if (!this.Tags) this.Tags = []
769
770 const tag = this.Tags.map(t => ({
771 type: 'Hashtag' as 'Hashtag',
772 name: t.name
773 }))
774
775 let language
776 if (this.language) {
777 language = {
778 identifier: this.language + '',
779 name: this.getLanguageLabel()
780 }
781 }
782
783 let category
784 if (this.category) {
785 category = {
786 identifier: this.category + '',
787 name: this.getCategoryLabel()
788 }
789 }
790
791 let licence
792 if (this.licence) {
793 licence = {
794 identifier: this.licence + '',
795 name: this.getLicenceLabel()
796 }
797 }
798
799 let likesObject
800 let dislikesObject
801
802 if (Array.isArray(this.AccountVideoRates)) {
803 const likes: string[] = []
804 const dislikes: string[] = []
805
806 for (const rate of this.AccountVideoRates) {
807 if (rate.type === 'like') {
808 likes.push(rate.Account.url)
809 } else if (rate.type === 'dislike') {
810 dislikes.push(rate.Account.url)
811 }
812 }
813
814 likesObject = activityPubCollection(likes)
815 dislikesObject = activityPubCollection(dislikes)
816 }
817
818 let sharesObject
819 if (Array.isArray(this.VideoShares)) {
820 const shares: string[] = []
821
822 for (const videoShare of this.VideoShares) {
823 const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Account)
824 shares.push(shareUrl)
825 }
826
827 sharesObject = activityPubCollection(shares)
828 }
829
830 const url = []
831 for (const file of this.VideoFiles) {
832 url.push({
833 type: 'Link',
834 mimeType: 'video/' + file.extname.replace('.', ''),
835 url: this.getVideoFileUrl(file, baseUrlHttp),
836 width: file.resolution,
837 size: file.size
838 })
839
840 url.push({
841 type: 'Link',
842 mimeType: 'application/x-bittorrent',
843 url: this.getTorrentUrl(file, baseUrlHttp),
844 width: file.resolution
845 })
846
847 url.push({
848 type: 'Link',
849 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
850 url: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
851 width: file.resolution
852 })
853 }
854
855 // Add video url too
856 url.push({
857 type: 'Link',
858 mimeType: 'text/html',
859 url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
860 })
861
862 return {
863 type: 'Video' as 'Video',
864 id: this.url,
865 name: this.name,
866 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
867 duration: 'PT' + this.duration + 'S',
868 uuid: this.uuid,
869 tag,
870 category,
871 licence,
872 language,
873 views: this.views,
874 nsfw: this.nsfw,
875 published: this.createdAt.toISOString(),
876 updated: this.updatedAt.toISOString(),
877 mediaType: 'text/markdown',
878 content: this.getTruncatedDescription(),
879 icon: {
880 type: 'Image',
881 url: this.getThumbnailUrl(baseUrlHttp),
882 mediaType: 'image/jpeg',
883 width: THUMBNAILS_SIZE.width,
884 height: THUMBNAILS_SIZE.height
885 },
886 url,
887 likes: likesObject,
888 dislikes: dislikesObject,
889 shares: sharesObject
890 }
891 }
892
893 getTruncatedDescription () {
894 if (!this.description) return null
895
896 const options = {
897 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
898 }
899
900 return truncate(this.description, options)
901 }
902
903 optimizeOriginalVideofile = async function () {
904 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
905 const newExtname = '.mp4'
906 const inputVideoFile = this.getOriginalFile()
907 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
908 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
909
910 const transcodeOptions = {
911 inputPath: videoInputPath,
912 outputPath: videoOutputPath
913 }
914
915 try {
916 // Could be very long!
917 await transcode(transcodeOptions)
918
919 await unlinkPromise(videoInputPath)
920
921 // Important to do this before getVideoFilename() to take in account the new file extension
922 inputVideoFile.set('extname', newExtname)
923
924 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
925 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
926
927 inputVideoFile.set('size', stats.size)
928
929 await this.createTorrentAndSetInfoHash(inputVideoFile)
930 await inputVideoFile.save()
931
932 } catch (err) {
933 // Auto destruction...
934 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
935
936 throw err
937 }
938 }
939
940 transcodeOriginalVideofile = async function (resolution: VideoResolution) {
941 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
942 const extname = '.mp4'
943
944 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
945 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
946
947 const newVideoFile = new VideoFileModel({
948 resolution,
949 extname,
950 size: 0,
951 videoId: this.id
952 })
953 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
954
955 const transcodeOptions = {
956 inputPath: videoInputPath,
957 outputPath: videoOutputPath,
958 resolution
959 }
960
961 await transcode(transcodeOptions)
962
963 const stats = await statPromise(videoOutputPath)
964
965 newVideoFile.set('size', stats.size)
966
967 await this.createTorrentAndSetInfoHash(newVideoFile)
968
969 await newVideoFile.save()
970
971 this.VideoFiles.push(newVideoFile)
972 }
973
974 getOriginalFileHeight () {
975 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
976
977 return getVideoFileHeight(originalFilePath)
978 }
979
980 getDescriptionPath () {
981 return `/api/${API_VERSION}/videos/${this.uuid}/description`
982 }
983
984 getCategoryLabel () {
985 let categoryLabel = VIDEO_CATEGORIES[this.category]
986 if (!categoryLabel) categoryLabel = 'Misc'
987
988 return categoryLabel
989 }
990
991 getLicenceLabel () {
992 let licenceLabel = VIDEO_LICENCES[this.licence]
993 if (!licenceLabel) licenceLabel = 'Unknown'
994
995 return licenceLabel
996 }
997
998 getLanguageLabel () {
999 let languageLabel = VIDEO_LANGUAGES[this.language]
1000 if (!languageLabel) languageLabel = 'Unknown'
1001
1002 return languageLabel
1003 }
1004
1005 removeThumbnail () {
1006 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1007 return unlinkPromise(thumbnailPath)
1008 }
1009
1010 removePreview () {
1011 // Same name than video thumbnail
1012 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1013 }
1014
1015 removeFile (videoFile: VideoFileModel) {
1016 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1017 return unlinkPromise(filePath)
1018 }
1019
1020 removeTorrent (videoFile: VideoFileModel) {
1021 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1022 return unlinkPromise(torrentPath)
1023 }
1024
1025 private getBaseUrls () {
1026 let baseUrlHttp
1027 let baseUrlWs
1028
1029 if (this.isOwned()) {
1030 baseUrlHttp = CONFIG.WEBSERVER.URL
1031 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1032 } else {
1033 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Server.host
1034 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Server.host
1035 }
1036
1037 return { baseUrlHttp, baseUrlWs }
1038 }
1039
1040 private getThumbnailUrl (baseUrlHttp: string) {
1041 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1042 }
1043
1044 private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1045 return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1046 }
1047
1048 private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1049 return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1050 }
1051
1052 private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1053 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1054 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1055 const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1056
1057 const magnetHash = {
1058 xs,
1059 announce,
1060 urlList,
1061 infoHash: videoFile.infoHash,
1062 name: this.name
1063 }
1064
1065 return magnetUtil.encode(magnetHash)
1066 }
1067 }