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