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