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