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