]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Add Podcast RSS feeds (#5487)
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
1 import Bluebird from 'bluebird'
2 import { remove } from 'fs-extra'
3 import { maxBy, minBy } from 'lodash'
4 import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
5 import {
6 AfterCreate,
7 AfterDestroy,
8 AfterUpdate,
9 AllowNull,
10 BeforeDestroy,
11 BelongsTo,
12 BelongsToMany,
13 Column,
14 CreatedAt,
15 DataType,
16 Default,
17 ForeignKey,
18 HasMany,
19 HasOne,
20 Is,
21 IsInt,
22 IsUUID,
23 Min,
24 Model,
25 Scopes,
26 Table,
27 UpdatedAt
28 } from 'sequelize-typescript'
29 import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30 import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
31 import { LiveManager } from '@server/lib/live/live-manager'
32 import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
33 import { tracer } from '@server/lib/opentelemetry/tracing'
34 import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
35 import { Hooks } from '@server/lib/plugins/hooks'
36 import { VideoPathManager } from '@server/lib/video-path-manager'
37 import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
38 import { getServerActor } from '@server/models/application/application'
39 import { ModelCache } from '@server/models/shared/model-cache'
40 import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
41 import { uuidToShort } from '@shared/extra-utils'
42 import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@shared/ffmpeg'
43 import {
44 ResultList,
45 ThumbnailType,
46 UserRight,
47 Video,
48 VideoDetails,
49 VideoFile,
50 VideoInclude,
51 VideoObject,
52 VideoPrivacy,
53 VideoRateType,
54 VideoState,
55 VideoStorage,
56 VideoStreamingPlaylistType
57 } from '@shared/models'
58 import { AttributesOnly } from '@shared/typescript-utils'
59 import { peertubeTruncate } from '../../helpers/core-utils'
60 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
61 import { exists, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
62 import {
63 isVideoDescriptionValid,
64 isVideoDurationValid,
65 isVideoNameValid,
66 isVideoPrivacyValid,
67 isVideoStateValid,
68 isVideoSupportValid
69 } from '../../helpers/custom-validators/videos'
70 import { logger } from '../../helpers/logger'
71 import { CONFIG } from '../../initializers/config'
72 import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
73 import { sendDeleteVideo } from '../../lib/activitypub/send'
74 import {
75 MChannel,
76 MChannelAccountDefault,
77 MChannelId,
78 MStreamingPlaylist,
79 MStreamingPlaylistFilesVideo,
80 MUserAccountId,
81 MUserId,
82 MVideo,
83 MVideoAccountLight,
84 MVideoAccountLightBlacklistAllFiles,
85 MVideoAP,
86 MVideoDetails,
87 MVideoFileVideo,
88 MVideoFormattable,
89 MVideoFormattableDetails,
90 MVideoForUser,
91 MVideoFullLight,
92 MVideoId,
93 MVideoImmutable,
94 MVideoThumbnail,
95 MVideoThumbnailBlacklist,
96 MVideoWithAllFiles,
97 MVideoWithFile
98 } from '../../types/models'
99 import { MThumbnail } from '../../types/models/video/thumbnail'
100 import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
101 import { VideoAbuseModel } from '../abuse/video-abuse'
102 import { AccountModel } from '../account/account'
103 import { AccountVideoRateModel } from '../account/account-video-rate'
104 import { ActorModel } from '../actor/actor'
105 import { ActorImageModel } from '../actor/actor-image'
106 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
107 import { ServerModel } from '../server/server'
108 import { TrackerModel } from '../server/tracker'
109 import { VideoTrackerModel } from '../server/video-tracker'
110 import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
111 import { UserModel } from '../user/user'
112 import { UserVideoHistoryModel } from '../user/user-video-history'
113 import { VideoViewModel } from '../view/video-view'
114 import {
115 videoFilesModelToFormattedJSON,
116 VideoFormattingJSONOptions,
117 videoModelToActivityPubObject,
118 videoModelToFormattedDetailsJSON,
119 videoModelToFormattedJSON
120 } from './formatter/video-format-utils'
121 import { ScheduleVideoUpdateModel } from './schedule-video-update'
122 import {
123 BuildVideosListQueryOptions,
124 DisplayOnlyForFollowerOptions,
125 VideoModelGetQueryBuilder,
126 VideosIdListQueryBuilder,
127 VideosModelListQueryBuilder
128 } from './sql/video'
129 import { TagModel } from './tag'
130 import { ThumbnailModel } from './thumbnail'
131 import { VideoBlacklistModel } from './video-blacklist'
132 import { VideoCaptionModel } from './video-caption'
133 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
134 import { VideoCommentModel } from './video-comment'
135 import { VideoFileModel } from './video-file'
136 import { VideoImportModel } from './video-import'
137 import { VideoJobInfoModel } from './video-job-info'
138 import { VideoLiveModel } from './video-live'
139 import { VideoPlaylistElementModel } from './video-playlist-element'
140 import { VideoShareModel } from './video-share'
141 import { VideoSourceModel } from './video-source'
142 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
143 import { VideoTagModel } from './video-tag'
144
145 export enum ScopeNames {
146 FOR_API = 'FOR_API',
147 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
148 WITH_TAGS = 'WITH_TAGS',
149 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES',
150 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
151 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
152 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
153 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
154 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
155 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
156 }
157
158 export type ForAPIOptions = {
159 ids?: number[]
160
161 videoPlaylistId?: number
162
163 withAccountBlockerIds?: number[]
164 }
165
166 @Scopes(() => ({
167 [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: {
168 attributes: [ 'id', 'url', 'uuid', 'remote' ]
169 },
170 [ScopeNames.FOR_API]: (options: ForAPIOptions) => {
171 const include: Includeable[] = [
172 {
173 model: VideoChannelModel.scope({
174 method: [
175 VideoChannelScopeNames.SUMMARY, {
176 withAccount: true,
177 withAccountBlockerIds: options.withAccountBlockerIds
178 } as SummaryOptions
179 ]
180 }),
181 required: true
182 },
183 {
184 attributes: [ 'type', 'filename' ],
185 model: ThumbnailModel,
186 required: false
187 }
188 ]
189
190 const query: FindOptions = {}
191
192 if (options.ids) {
193 query.where = {
194 id: {
195 [Op.in]: options.ids
196 }
197 }
198 }
199
200 if (options.videoPlaylistId) {
201 include.push({
202 model: VideoPlaylistElementModel.unscoped(),
203 required: true,
204 where: {
205 videoPlaylistId: options.videoPlaylistId
206 }
207 })
208 }
209
210 query.include = include
211
212 return query
213 },
214 [ScopeNames.WITH_THUMBNAILS]: {
215 include: [
216 {
217 model: ThumbnailModel,
218 required: false
219 }
220 ]
221 },
222 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
223 include: [
224 {
225 model: VideoChannelModel.unscoped(),
226 required: true,
227 include: [
228 {
229 attributes: {
230 exclude: [ 'privateKey', 'publicKey' ]
231 },
232 model: ActorModel.unscoped(),
233 required: true,
234 include: [
235 {
236 attributes: [ 'host' ],
237 model: ServerModel.unscoped(),
238 required: false
239 },
240 {
241 model: ActorImageModel,
242 as: 'Avatars',
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: ActorImageModel,
265 as: 'Avatars',
266 required: false
267 }
268 ]
269 }
270 ]
271 }
272 ]
273 }
274 ]
275 },
276 [ScopeNames.WITH_TAGS]: {
277 include: [ TagModel ]
278 },
279 [ScopeNames.WITH_BLACKLISTED]: {
280 include: [
281 {
282 attributes: [ 'id', 'reason', 'unfederated' ],
283 model: VideoBlacklistModel,
284 required: false
285 }
286 ]
287 },
288 [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => {
289 let subInclude: any[] = []
290
291 if (withRedundancies === true) {
292 subInclude = [
293 {
294 attributes: [ 'fileUrl' ],
295 model: VideoRedundancyModel.unscoped(),
296 required: false
297 }
298 ]
299 }
300
301 return {
302 include: [
303 {
304 model: VideoFileModel,
305 separate: true,
306 required: false,
307 include: subInclude
308 }
309 ]
310 }
311 },
312 [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => {
313 const subInclude: IncludeOptions[] = [
314 {
315 model: VideoFileModel,
316 required: false
317 }
318 ]
319
320 if (withRedundancies === true) {
321 subInclude.push({
322 attributes: [ 'fileUrl' ],
323 model: VideoRedundancyModel.unscoped(),
324 required: false
325 })
326 }
327
328 return {
329 include: [
330 {
331 model: VideoStreamingPlaylistModel.unscoped(),
332 required: false,
333 separate: true,
334 include: subInclude
335 }
336 ]
337 }
338 },
339 [ScopeNames.WITH_SCHEDULED_UPDATE]: {
340 include: [
341 {
342 model: ScheduleVideoUpdateModel.unscoped(),
343 required: false
344 }
345 ]
346 },
347 [ScopeNames.WITH_USER_HISTORY]: (userId: number) => {
348 return {
349 include: [
350 {
351 attributes: [ 'currentTime' ],
352 model: UserVideoHistoryModel.unscoped(),
353 required: false,
354 where: {
355 userId
356 }
357 }
358 ]
359 }
360 }
361 }))
362 @Table({
363 tableName: 'video',
364 indexes: [
365 buildTrigramSearchIndex('video_name_trigram', 'name'),
366
367 { fields: [ 'createdAt' ] },
368 {
369 fields: [
370 { name: 'publishedAt', order: 'DESC' },
371 { name: 'id', order: 'ASC' }
372 ]
373 },
374 { fields: [ 'duration' ] },
375 {
376 fields: [
377 { name: 'views', order: 'DESC' },
378 { name: 'id', order: 'ASC' }
379 ]
380 },
381 { fields: [ 'channelId' ] },
382 {
383 fields: [ 'originallyPublishedAt' ],
384 where: {
385 originallyPublishedAt: {
386 [Op.ne]: null
387 }
388 }
389 },
390 {
391 fields: [ 'category' ], // We don't care videos with an unknown category
392 where: {
393 category: {
394 [Op.ne]: null
395 }
396 }
397 },
398 {
399 fields: [ 'licence' ], // We don't care videos with an unknown licence
400 where: {
401 licence: {
402 [Op.ne]: null
403 }
404 }
405 },
406 {
407 fields: [ 'language' ], // We don't care videos with an unknown language
408 where: {
409 language: {
410 [Op.ne]: null
411 }
412 }
413 },
414 {
415 fields: [ 'nsfw' ], // Most of the videos are not NSFW
416 where: {
417 nsfw: true
418 }
419 },
420 {
421 fields: [ 'remote' ], // Only index local videos
422 where: {
423 remote: false
424 }
425 },
426 {
427 fields: [ 'uuid' ],
428 unique: true
429 },
430 {
431 fields: [ 'url' ],
432 unique: true
433 }
434 ]
435 })
436 export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
437
438 @AllowNull(false)
439 @Default(DataType.UUIDV4)
440 @IsUUID(4)
441 @Column(DataType.UUID)
442 uuid: string
443
444 @AllowNull(false)
445 @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
446 @Column
447 name: string
448
449 @AllowNull(true)
450 @Default(null)
451 @Column
452 category: number
453
454 @AllowNull(true)
455 @Default(null)
456 @Column
457 licence: number
458
459 @AllowNull(true)
460 @Default(null)
461 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
462 language: string
463
464 @AllowNull(false)
465 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
466 @Column
467 privacy: VideoPrivacy
468
469 @AllowNull(false)
470 @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
471 @Column
472 nsfw: boolean
473
474 @AllowNull(true)
475 @Default(null)
476 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
477 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
478 description: string
479
480 @AllowNull(true)
481 @Default(null)
482 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
483 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
484 support: string
485
486 @AllowNull(false)
487 @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
488 @Column
489 duration: number
490
491 @AllowNull(false)
492 @Default(0)
493 @IsInt
494 @Min(0)
495 @Column
496 views: number
497
498 @AllowNull(false)
499 @Default(0)
500 @IsInt
501 @Min(0)
502 @Column
503 likes: number
504
505 @AllowNull(false)
506 @Default(0)
507 @IsInt
508 @Min(0)
509 @Column
510 dislikes: number
511
512 @AllowNull(false)
513 @Column
514 remote: boolean
515
516 @AllowNull(false)
517 @Default(false)
518 @Column
519 isLive: boolean
520
521 @AllowNull(false)
522 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
523 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
524 url: string
525
526 @AllowNull(false)
527 @Column
528 commentsEnabled: boolean
529
530 @AllowNull(false)
531 @Column
532 downloadEnabled: boolean
533
534 @AllowNull(false)
535 @Column
536 waitTranscoding: boolean
537
538 @AllowNull(false)
539 @Default(null)
540 @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
541 @Column
542 state: VideoState
543
544 @CreatedAt
545 createdAt: Date
546
547 @UpdatedAt
548 updatedAt: Date
549
550 @AllowNull(false)
551 @Default(DataType.NOW)
552 @Column
553 publishedAt: Date
554
555 @AllowNull(true)
556 @Default(null)
557 @Column
558 originallyPublishedAt: Date
559
560 @ForeignKey(() => VideoChannelModel)
561 @Column
562 channelId: number
563
564 @BelongsTo(() => VideoChannelModel, {
565 foreignKey: {
566 allowNull: true
567 },
568 onDelete: 'cascade'
569 })
570 VideoChannel: VideoChannelModel
571
572 @BelongsToMany(() => TagModel, {
573 foreignKey: 'videoId',
574 through: () => VideoTagModel,
575 onDelete: 'CASCADE'
576 })
577 Tags: TagModel[]
578
579 @BelongsToMany(() => TrackerModel, {
580 foreignKey: 'videoId',
581 through: () => VideoTrackerModel,
582 onDelete: 'CASCADE'
583 })
584 Trackers: TrackerModel[]
585
586 @HasMany(() => ThumbnailModel, {
587 foreignKey: {
588 name: 'videoId',
589 allowNull: true
590 },
591 hooks: true,
592 onDelete: 'cascade'
593 })
594 Thumbnails: ThumbnailModel[]
595
596 @HasMany(() => VideoPlaylistElementModel, {
597 foreignKey: {
598 name: 'videoId',
599 allowNull: true
600 },
601 onDelete: 'set null'
602 })
603 VideoPlaylistElements: VideoPlaylistElementModel[]
604
605 @HasOne(() => VideoSourceModel, {
606 foreignKey: {
607 name: 'videoId',
608 allowNull: true
609 },
610 onDelete: 'CASCADE'
611 })
612 VideoSource: VideoSourceModel
613
614 @HasMany(() => VideoAbuseModel, {
615 foreignKey: {
616 name: 'videoId',
617 allowNull: true
618 },
619 onDelete: 'set null'
620 })
621 VideoAbuses: VideoAbuseModel[]
622
623 @HasMany(() => VideoFileModel, {
624 foreignKey: {
625 name: 'videoId',
626 allowNull: true
627 },
628 hooks: true,
629 onDelete: 'cascade'
630 })
631 VideoFiles: VideoFileModel[]
632
633 @HasMany(() => VideoStreamingPlaylistModel, {
634 foreignKey: {
635 name: 'videoId',
636 allowNull: false
637 },
638 hooks: true,
639 onDelete: 'cascade'
640 })
641 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
642
643 @HasMany(() => VideoShareModel, {
644 foreignKey: {
645 name: 'videoId',
646 allowNull: false
647 },
648 onDelete: 'cascade'
649 })
650 VideoShares: VideoShareModel[]
651
652 @HasMany(() => AccountVideoRateModel, {
653 foreignKey: {
654 name: 'videoId',
655 allowNull: false
656 },
657 onDelete: 'cascade'
658 })
659 AccountVideoRates: AccountVideoRateModel[]
660
661 @HasMany(() => VideoCommentModel, {
662 foreignKey: {
663 name: 'videoId',
664 allowNull: false
665 },
666 onDelete: 'cascade',
667 hooks: true
668 })
669 VideoComments: VideoCommentModel[]
670
671 @HasMany(() => VideoViewModel, {
672 foreignKey: {
673 name: 'videoId',
674 allowNull: false
675 },
676 onDelete: 'cascade'
677 })
678 VideoViews: VideoViewModel[]
679
680 @HasMany(() => UserVideoHistoryModel, {
681 foreignKey: {
682 name: 'videoId',
683 allowNull: false
684 },
685 onDelete: 'cascade'
686 })
687 UserVideoHistories: UserVideoHistoryModel[]
688
689 @HasOne(() => ScheduleVideoUpdateModel, {
690 foreignKey: {
691 name: 'videoId',
692 allowNull: false
693 },
694 onDelete: 'cascade'
695 })
696 ScheduleVideoUpdate: ScheduleVideoUpdateModel
697
698 @HasOne(() => VideoBlacklistModel, {
699 foreignKey: {
700 name: 'videoId',
701 allowNull: false
702 },
703 onDelete: 'cascade'
704 })
705 VideoBlacklist: VideoBlacklistModel
706
707 @HasOne(() => VideoLiveModel, {
708 foreignKey: {
709 name: 'videoId',
710 allowNull: false
711 },
712 hooks: true,
713 onDelete: 'cascade'
714 })
715 VideoLive: VideoLiveModel
716
717 @HasOne(() => VideoImportModel, {
718 foreignKey: {
719 name: 'videoId',
720 allowNull: true
721 },
722 onDelete: 'set null'
723 })
724 VideoImport: VideoImportModel
725
726 @HasMany(() => VideoCaptionModel, {
727 foreignKey: {
728 name: 'videoId',
729 allowNull: false
730 },
731 onDelete: 'cascade',
732 hooks: true,
733 ['separate' as any]: true
734 })
735 VideoCaptions: VideoCaptionModel[]
736
737 @HasOne(() => VideoJobInfoModel, {
738 foreignKey: {
739 name: 'videoId',
740 allowNull: false
741 },
742 onDelete: 'cascade'
743 })
744 VideoJobInfo: VideoJobInfoModel
745
746 @AfterCreate
747 static notifyCreate (video: MVideo) {
748 InternalEventEmitter.Instance.emit('video-created', { video })
749 }
750
751 @AfterUpdate
752 static notifyUpdate (video: MVideo) {
753 InternalEventEmitter.Instance.emit('video-updated', { video })
754 }
755
756 @AfterDestroy
757 static notifyDestroy (video: MVideo) {
758 InternalEventEmitter.Instance.emit('video-deleted', { video })
759 }
760
761 @BeforeDestroy
762 static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) {
763 if (!instance.isOwned()) return undefined
764
765 // Lazy load channels
766 if (!instance.VideoChannel) {
767 instance.VideoChannel = await instance.$get('VideoChannel', {
768 include: [
769 ActorModel,
770 AccountModel
771 ],
772 transaction: options.transaction
773 }) as MChannelAccountDefault
774 }
775
776 return sendDeleteVideo(instance, options.transaction)
777 }
778
779 @BeforeDestroy
780 static async removeFiles (instance: VideoModel, options) {
781 const tasks: Promise<any>[] = []
782
783 logger.info('Removing files of video %s.', instance.url)
784
785 if (instance.isOwned()) {
786 if (!Array.isArray(instance.VideoFiles)) {
787 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
788 }
789
790 // Remove physical files and torrents
791 instance.VideoFiles.forEach(file => {
792 tasks.push(instance.removeWebTorrentFile(file))
793 })
794
795 // Remove playlists file
796 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
797 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
798 }
799
800 for (const p of instance.VideoStreamingPlaylists) {
801 tasks.push(instance.removeStreamingPlaylistFiles(p))
802 }
803 }
804
805 // Do not wait video deletion because we could be in a transaction
806 Promise.all(tasks)
807 .then(() => logger.info('Removed files of video %s.', instance.url))
808 .catch(err => logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err }))
809
810 return undefined
811 }
812
813 @BeforeDestroy
814 static stopLiveIfNeeded (instance: VideoModel) {
815 if (!instance.isLive) return
816
817 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
818
819 LiveManager.Instance.stopSessionOf(instance.uuid, null)
820 }
821
822 @BeforeDestroy
823 static invalidateCache (instance: VideoModel) {
824 ModelCache.Instance.invalidateCache('video', instance.id)
825 }
826
827 @BeforeDestroy
828 static async saveEssentialDataToAbuses (instance: VideoModel, options) {
829 const tasks: Promise<any>[] = []
830
831 if (!Array.isArray(instance.VideoAbuses)) {
832 instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
833
834 if (instance.VideoAbuses.length === 0) return undefined
835 }
836
837 logger.info('Saving video abuses details of video %s.', instance.url)
838
839 if (!instance.Trackers) instance.Trackers = await instance.$get('Trackers', { transaction: options.transaction })
840 const details = instance.toFormattedDetailsJSON()
841
842 for (const abuse of instance.VideoAbuses) {
843 abuse.deletedVideo = details
844 tasks.push(abuse.save({ transaction: options.transaction }))
845 }
846
847 await Promise.all(tasks)
848 }
849
850 static listLocalIds (): Promise<number[]> {
851 const query = {
852 attributes: [ 'id' ],
853 raw: true,
854 where: {
855 remote: false
856 }
857 }
858
859 return VideoModel.findAll(query)
860 .then(rows => rows.map(r => r.id))
861 }
862
863 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
864 function getRawQuery (select: string) {
865 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
866 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
867 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
868 'WHERE "Account"."actorId" = ' + actorId
869 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
870 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
871 'WHERE "VideoShare"."actorId" = ' + actorId
872
873 return `(${queryVideo}) UNION (${queryVideoShare})`
874 }
875
876 const rawQuery = getRawQuery('"Video"."id"')
877 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
878
879 const query = {
880 distinct: true,
881 offset: start,
882 limit: count,
883 order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ]),
884 where: {
885 id: {
886 [Op.in]: Sequelize.literal('(' + rawQuery + ')')
887 },
888 [Op.or]: getPrivaciesForFederation()
889 },
890 include: [
891 {
892 attributes: [ 'filename', 'language', 'fileUrl' ],
893 model: VideoCaptionModel.unscoped(),
894 required: false
895 },
896 {
897 attributes: [ 'id', 'url' ],
898 model: VideoShareModel.unscoped(),
899 required: false,
900 // We only want videos shared by this actor
901 where: {
902 [Op.and]: [
903 {
904 id: {
905 [Op.not]: null
906 }
907 },
908 {
909 actorId
910 }
911 ]
912 },
913 include: [
914 {
915 attributes: [ 'id', 'url' ],
916 model: ActorModel.unscoped()
917 }
918 ]
919 },
920 {
921 model: VideoChannelModel.unscoped(),
922 required: true,
923 include: [
924 {
925 attributes: [ 'name' ],
926 model: AccountModel.unscoped(),
927 required: true,
928 include: [
929 {
930 attributes: [ 'id', 'url', 'followersUrl' ],
931 model: ActorModel.unscoped(),
932 required: true
933 }
934 ]
935 },
936 {
937 attributes: [ 'id', 'url', 'followersUrl' ],
938 model: ActorModel.unscoped(),
939 required: true
940 }
941 ]
942 },
943 {
944 model: VideoStreamingPlaylistModel.unscoped(),
945 required: false,
946 include: [
947 {
948 model: VideoFileModel,
949 required: false
950 }
951 ]
952 },
953 VideoLiveModel.unscoped(),
954 VideoFileModel,
955 TagModel
956 ]
957 }
958
959 return Bluebird.all([
960 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
961 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
962 ]).then(([ rows, totals ]) => {
963 // totals: totalVideos + totalVideoShares
964 let totalVideos = 0
965 let totalVideoShares = 0
966 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
967 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
968
969 const total = totalVideos + totalVideoShares
970 return {
971 data: rows,
972 total
973 }
974 })
975 }
976
977 static async listPublishedLiveUUIDs () {
978 const options = {
979 attributes: [ 'uuid' ],
980 where: {
981 isLive: true,
982 remote: false,
983 state: VideoState.PUBLISHED
984 }
985 }
986
987 const result = await VideoModel.findAll(options)
988
989 return result.map(v => v.uuid)
990 }
991
992 static listUserVideosForApi (options: {
993 accountId: number
994 start: number
995 count: number
996 sort: string
997
998 channelId?: number
999 isLive?: boolean
1000 search?: string
1001 }) {
1002 const { accountId, channelId, start, count, sort, search, isLive } = options
1003
1004 function buildBaseQuery (forCount: boolean): FindOptions {
1005 const where: WhereOptions = {}
1006
1007 if (search) {
1008 where.name = {
1009 [Op.iLike]: '%' + search + '%'
1010 }
1011 }
1012
1013 if (exists(isLive)) {
1014 where.isLive = isLive
1015 }
1016
1017 const channelWhere = channelId
1018 ? { id: channelId }
1019 : {}
1020
1021 const baseQuery = {
1022 offset: start,
1023 limit: count,
1024 where,
1025 order: getVideoSort(sort),
1026 include: [
1027 {
1028 model: forCount
1029 ? VideoChannelModel.unscoped()
1030 : VideoChannelModel,
1031 required: true,
1032 where: channelWhere,
1033 include: [
1034 {
1035 model: forCount
1036 ? AccountModel.unscoped()
1037 : AccountModel,
1038 where: {
1039 id: accountId
1040 },
1041 required: true
1042 }
1043 ]
1044 }
1045 ]
1046 }
1047
1048 return baseQuery
1049 }
1050
1051 const countQuery = buildBaseQuery(true)
1052 const findQuery = buildBaseQuery(false)
1053
1054 const findScopes: (string | ScopeOptions)[] = [
1055 ScopeNames.WITH_SCHEDULED_UPDATE,
1056 ScopeNames.WITH_BLACKLISTED,
1057 ScopeNames.WITH_THUMBNAILS
1058 ]
1059
1060 return Promise.all([
1061 VideoModel.count(countQuery),
1062 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1063 ]).then(([ count, rows ]) => {
1064 return {
1065 data: rows,
1066 total: count
1067 }
1068 })
1069 }
1070
1071 static async listForApi (options: {
1072 start: number
1073 count: number
1074 sort: string
1075
1076 nsfw: boolean
1077 isLive?: boolean
1078 isLocal?: boolean
1079 include?: VideoInclude
1080
1081 hasFiles?: boolean // default false
1082 hasWebtorrentFiles?: boolean
1083 hasHLSFiles?: boolean
1084
1085 categoryOneOf?: number[]
1086 licenceOneOf?: number[]
1087 languageOneOf?: string[]
1088 tagsOneOf?: string[]
1089 tagsAllOf?: string[]
1090 privacyOneOf?: VideoPrivacy[]
1091
1092 accountId?: number
1093 videoChannelId?: number
1094
1095 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1096
1097 videoPlaylistId?: number
1098
1099 trendingDays?: number
1100
1101 user?: MUserAccountId
1102 historyOfUser?: MUserId
1103
1104 countVideos?: boolean
1105
1106 search?: string
1107
1108 excludeAlreadyWatched?: boolean
1109 }) {
1110 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1111 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1112
1113 const trendingDays = options.sort.endsWith('trending')
1114 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1115 : undefined
1116
1117 let trendingAlgorithm: string
1118 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1119 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1120
1121 const serverActor = await getServerActor()
1122
1123 const queryOptions = {
1124 ...pick(options, [
1125 'start',
1126 'count',
1127 'sort',
1128 'nsfw',
1129 'isLive',
1130 'categoryOneOf',
1131 'licenceOneOf',
1132 'languageOneOf',
1133 'tagsOneOf',
1134 'tagsAllOf',
1135 'privacyOneOf',
1136 'isLocal',
1137 'include',
1138 'displayOnlyForFollower',
1139 'hasFiles',
1140 'accountId',
1141 'videoChannelId',
1142 'videoPlaylistId',
1143 'user',
1144 'historyOfUser',
1145 'hasHLSFiles',
1146 'hasWebtorrentFiles',
1147 'search',
1148 'excludeAlreadyWatched'
1149 ]),
1150
1151 serverAccountIdForBlock: serverActor.Account.id,
1152 trendingDays,
1153 trendingAlgorithm
1154 }
1155
1156 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1157 }
1158
1159 static async searchAndPopulateAccountAndServer (options: {
1160 start: number
1161 count: number
1162 sort: string
1163
1164 nsfw?: boolean
1165 isLive?: boolean
1166 isLocal?: boolean
1167 include?: VideoInclude
1168
1169 categoryOneOf?: number[]
1170 licenceOneOf?: number[]
1171 languageOneOf?: string[]
1172 tagsOneOf?: string[]
1173 tagsAllOf?: string[]
1174 privacyOneOf?: VideoPrivacy[]
1175
1176 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1177
1178 user?: MUserAccountId
1179
1180 hasWebtorrentFiles?: boolean
1181 hasHLSFiles?: boolean
1182
1183 search?: string
1184
1185 host?: string
1186 startDate?: string // ISO 8601
1187 endDate?: string // ISO 8601
1188 originallyPublishedStartDate?: string
1189 originallyPublishedEndDate?: string
1190
1191 durationMin?: number // seconds
1192 durationMax?: number // seconds
1193 uuids?: string[]
1194
1195 excludeAlreadyWatched?: boolean
1196 }) {
1197 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1198 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1199
1200 const serverActor = await getServerActor()
1201
1202 const queryOptions = {
1203 ...pick(options, [
1204 'include',
1205 'nsfw',
1206 'isLive',
1207 'categoryOneOf',
1208 'licenceOneOf',
1209 'languageOneOf',
1210 'tagsOneOf',
1211 'tagsAllOf',
1212 'privacyOneOf',
1213 'user',
1214 'isLocal',
1215 'host',
1216 'start',
1217 'count',
1218 'sort',
1219 'startDate',
1220 'endDate',
1221 'originallyPublishedStartDate',
1222 'originallyPublishedEndDate',
1223 'durationMin',
1224 'durationMax',
1225 'hasHLSFiles',
1226 'hasWebtorrentFiles',
1227 'uuids',
1228 'search',
1229 'displayOnlyForFollower',
1230 'excludeAlreadyWatched'
1231 ]),
1232 serverAccountIdForBlock: serverActor.Account.id
1233 }
1234
1235 return VideoModel.getAvailableForApi(queryOptions)
1236 }
1237
1238 static countLives (options: {
1239 remote: boolean
1240 mode: 'published' | 'not-ended'
1241 }) {
1242 const query = {
1243 where: {
1244 remote: options.remote,
1245 isLive: true,
1246 state: options.mode === 'not-ended'
1247 ? { [Op.ne]: VideoState.LIVE_ENDED }
1248 : { [Op.eq]: VideoState.PUBLISHED }
1249 }
1250 }
1251
1252 return VideoModel.count(query)
1253 }
1254
1255 static countVideosUploadedByUserSince (userId: number, since: Date) {
1256 const options = {
1257 include: [
1258 {
1259 model: VideoChannelModel.unscoped(),
1260 required: true,
1261 include: [
1262 {
1263 model: AccountModel.unscoped(),
1264 required: true,
1265 include: [
1266 {
1267 model: UserModel.unscoped(),
1268 required: true,
1269 where: {
1270 id: userId
1271 }
1272 }
1273 ]
1274 }
1275 ]
1276 }
1277 ],
1278 where: {
1279 createdAt: {
1280 [Op.gte]: since
1281 }
1282 }
1283 }
1284
1285 return VideoModel.unscoped().count(options)
1286 }
1287
1288 static countLivesOfAccount (accountId: number) {
1289 const options = {
1290 where: {
1291 remote: false,
1292 isLive: true,
1293 state: {
1294 [Op.ne]: VideoState.LIVE_ENDED
1295 }
1296 },
1297 include: [
1298 {
1299 required: true,
1300 model: VideoChannelModel.unscoped(),
1301 where: {
1302 accountId
1303 }
1304 }
1305 ]
1306 }
1307
1308 return VideoModel.count(options)
1309 }
1310
1311 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1312 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1313
1314 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1315 }
1316
1317 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1318 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1319
1320 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1321 }
1322
1323 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1324 const fun = () => {
1325 const query = {
1326 where: buildWhereIdOrUUID(id),
1327 transaction: t
1328 }
1329
1330 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1331 }
1332
1333 return ModelCache.Instance.doCache({
1334 cacheType: 'load-video-immutable-id',
1335 key: '' + id,
1336 deleteKey: 'video',
1337 fun
1338 })
1339 }
1340
1341 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1342 const fun = () => {
1343 const query: FindOptions = {
1344 where: {
1345 url
1346 },
1347 transaction
1348 }
1349
1350 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1351 }
1352
1353 return ModelCache.Instance.doCache({
1354 cacheType: 'load-video-immutable-url',
1355 key: url,
1356 deleteKey: 'video',
1357 fun
1358 })
1359 }
1360
1361 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1362 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1363
1364 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1365 }
1366
1367 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1368 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1369
1370 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1371 }
1372
1373 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1374 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1375
1376 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1377 }
1378
1379 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1380 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1381
1382 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1383 }
1384
1385 static loadFull (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1386 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1387
1388 return queryBuilder.queryVideo({ id, transaction: t, type: 'full', userId })
1389 }
1390
1391 static loadForGetAPI (parameters: {
1392 id: number | string
1393 transaction?: Transaction
1394 userId?: number
1395 }): Promise<MVideoDetails> {
1396 const { id, transaction, userId } = parameters
1397 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1398
1399 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1400 }
1401
1402 static async getStats () {
1403 const serverActor = await getServerActor()
1404
1405 let totalLocalVideoViews = await VideoModel.sum('views', {
1406 where: {
1407 remote: false
1408 }
1409 })
1410
1411 // Sequelize could return null...
1412 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1413
1414 const baseOptions = {
1415 start: 0,
1416 count: 0,
1417 sort: '-publishedAt',
1418 nsfw: null,
1419 displayOnlyForFollower: {
1420 actorId: serverActor.id,
1421 orLocalVideos: true
1422 }
1423 }
1424
1425 const { total: totalLocalVideos } = await VideoModel.listForApi({
1426 ...baseOptions,
1427
1428 isLocal: true
1429 })
1430
1431 const { total: totalVideos } = await VideoModel.listForApi(baseOptions)
1432
1433 return {
1434 totalLocalVideos,
1435 totalLocalVideoViews,
1436 totalVideos
1437 }
1438 }
1439
1440 static incrementViews (id: number, views: number) {
1441 return VideoModel.increment('views', {
1442 by: views,
1443 where: {
1444 id
1445 }
1446 })
1447 }
1448
1449 static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
1450 const field = type === 'like'
1451 ? 'likes'
1452 : 'dislikes'
1453
1454 const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
1455
1456 return AccountVideoRateModel.sequelize.query(rawQuery, {
1457 transaction: t,
1458 replacements: { videoId, rateType: type, count },
1459 type: QueryTypes.UPDATE
1460 })
1461 }
1462
1463 static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
1464 const field = type === 'like'
1465 ? 'likes'
1466 : 'dislikes'
1467
1468 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1469 '(' +
1470 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1471 ') ' +
1472 'WHERE "video"."id" = :videoId'
1473
1474 return AccountVideoRateModel.sequelize.query(rawQuery, {
1475 transaction: t,
1476 replacements: { videoId, rateType: type },
1477 type: QueryTypes.UPDATE
1478 })
1479 }
1480
1481 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1482 // Instances only share videos
1483 const query = 'SELECT 1 FROM "videoShare" ' +
1484 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1485 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1486 'UNION ' +
1487 'SELECT 1 FROM "video" ' +
1488 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
1489 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
1490 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "account"."actorId" ' +
1491 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "video"."id" = $videoId ' +
1492 'LIMIT 1'
1493
1494 const options = {
1495 type: QueryTypes.SELECT as QueryTypes.SELECT,
1496 bind: { followerActorId, videoId },
1497 raw: true
1498 }
1499
1500 return VideoModel.sequelize.query(query, options)
1501 .then(results => results.length === 1)
1502 }
1503
1504 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1505 const options = {
1506 where: {
1507 channelId: ofChannel.id
1508 },
1509 transaction: t
1510 }
1511
1512 return VideoModel.update({ support: ofChannel.support }, options)
1513 }
1514
1515 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1516 const query = {
1517 attributes: [ 'id' ],
1518 where: {
1519 channelId: videoChannel.id
1520 }
1521 }
1522
1523 return VideoModel.findAll(query)
1524 .then(videos => videos.map(v => v.id))
1525 }
1526
1527 // threshold corresponds to how many video the field should have to be returned
1528 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1529 const serverActor = await getServerActor()
1530
1531 const queryOptions: BuildVideosListQueryOptions = {
1532 attributes: [ `"${field}"` ],
1533 group: `GROUP BY "${field}"`,
1534 having: `HAVING COUNT("${field}") >= ${threshold}`,
1535 start: 0,
1536 sort: 'random',
1537 count,
1538 serverAccountIdForBlock: serverActor.Account.id,
1539 displayOnlyForFollower: {
1540 actorId: serverActor.id,
1541 orLocalVideos: true
1542 }
1543 }
1544
1545 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1546
1547 return queryBuilder.queryVideoIds(queryOptions)
1548 .then(rows => rows.map(r => r[field]))
1549 }
1550
1551 static buildTrendingQuery (trendingDays: number) {
1552 return {
1553 attributes: [],
1554 subQuery: false,
1555 model: VideoViewModel,
1556 required: false,
1557 where: {
1558 startDate: {
1559 // FIXME: ts error
1560 [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1561 }
1562 }
1563 }
1564 }
1565
1566 private static async getAvailableForApi (
1567 options: BuildVideosListQueryOptions,
1568 countVideos = true
1569 ): Promise<ResultList<VideoModel>> {
1570 const span = tracer.startSpan('peertube.VideoModel.getAvailableForApi')
1571
1572 function getCount () {
1573 if (countVideos !== true) return Promise.resolve(undefined)
1574
1575 const countOptions = Object.assign({}, options, { isCount: true })
1576 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1577
1578 return queryBuilder.countVideoIds(countOptions)
1579 }
1580
1581 function getModels () {
1582 if (options.count === 0) return Promise.resolve([])
1583
1584 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1585
1586 return queryBuilder.queryVideos(options)
1587 }
1588
1589 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1590
1591 span.end()
1592
1593 return {
1594 data: rows,
1595 total: count
1596 }
1597 }
1598
1599 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1600 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1601 throw new Error('Try to filter all-local but user cannot see all videos')
1602 }
1603 }
1604
1605 private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
1606 if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1607 throw new Error('Try to choose video privacies but user cannot see all videos')
1608 }
1609 }
1610
1611 private static isPrivateInclude (include: VideoInclude) {
1612 return include & VideoInclude.BLACKLISTED ||
1613 include & VideoInclude.BLOCKED_OWNER ||
1614 include & VideoInclude.NOT_PUBLISHED_STATE
1615 }
1616
1617 isBlacklisted () {
1618 return !!this.VideoBlacklist
1619 }
1620
1621 isBlocked () {
1622 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1623 }
1624
1625 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1626 const files = this.getAllFiles()
1627 const file = fun(files, file => file.resolution)
1628 if (!file) return undefined
1629
1630 if (file.videoId) {
1631 return Object.assign(file, { Video: this })
1632 }
1633
1634 if (file.videoStreamingPlaylistId) {
1635 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1636
1637 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1638 }
1639
1640 throw new Error('File is not associated to a video of a playlist')
1641 }
1642
1643 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1644 return this.getQualityFileBy(maxBy)
1645 }
1646
1647 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1648 return this.getQualityFileBy(minBy)
1649 }
1650
1651 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1652 if (Array.isArray(this.VideoFiles) === false) return undefined
1653
1654 const file = this.VideoFiles.find(f => f.resolution === resolution)
1655 if (!file) return undefined
1656
1657 return Object.assign(file, { Video: this })
1658 }
1659
1660 hasWebTorrentFiles () {
1661 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1662 }
1663
1664 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1665 thumbnail.videoId = this.id
1666
1667 const savedThumbnail = await thumbnail.save({ transaction })
1668
1669 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1670
1671 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1672 this.Thumbnails.push(savedThumbnail)
1673 }
1674
1675 getMiniature () {
1676 if (Array.isArray(this.Thumbnails) === false) return undefined
1677
1678 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1679 }
1680
1681 hasPreview () {
1682 return !!this.getPreview()
1683 }
1684
1685 getPreview () {
1686 if (Array.isArray(this.Thumbnails) === false) return undefined
1687
1688 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1689 }
1690
1691 isOwned () {
1692 return this.remote === false
1693 }
1694
1695 getWatchStaticPath () {
1696 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1697 }
1698
1699 getEmbedStaticPath () {
1700 return buildVideoEmbedPath(this)
1701 }
1702
1703 getMiniatureStaticPath () {
1704 const thumbnail = this.getMiniature()
1705 if (!thumbnail) return null
1706
1707 return thumbnail.getLocalStaticPath()
1708 }
1709
1710 getPreviewStaticPath () {
1711 const preview = this.getPreview()
1712 if (!preview) return null
1713
1714 return preview.getLocalStaticPath()
1715 }
1716
1717 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1718 return videoModelToFormattedJSON(this, options)
1719 }
1720
1721 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1722 return videoModelToFormattedDetailsJSON(this)
1723 }
1724
1725 getFormattedWebVideoFilesJSON (includeMagnet = true): VideoFile[] {
1726 return videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
1727 }
1728
1729 getFormattedHLSVideoFilesJSON (includeMagnet = true): VideoFile[] {
1730 let acc: VideoFile[] = []
1731
1732 for (const p of this.VideoStreamingPlaylists) {
1733 acc = acc.concat(videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }))
1734 }
1735
1736 return acc
1737 }
1738
1739 getFormattedAllVideoFilesJSON (includeMagnet = true): VideoFile[] {
1740 let files: VideoFile[] = []
1741
1742 if (Array.isArray(this.VideoFiles)) {
1743 files = files.concat(this.getFormattedWebVideoFilesJSON(includeMagnet))
1744 }
1745
1746 if (Array.isArray(this.VideoStreamingPlaylists)) {
1747 files = files.concat(this.getFormattedHLSVideoFilesJSON(includeMagnet))
1748 }
1749
1750 return files
1751 }
1752
1753 toActivityPubObject (this: MVideoAP): Promise<VideoObject> {
1754 return Hooks.wrapObject(
1755 videoModelToActivityPubObject(this),
1756 'filter:activity-pub.video.json-ld.build.result',
1757 { video: this }
1758 )
1759 }
1760
1761 getTruncatedDescription () {
1762 if (!this.description) return null
1763
1764 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1765 return peertubeTruncate(this.description, { length: maxLength })
1766 }
1767
1768 getAllFiles () {
1769 let files: MVideoFile[] = []
1770
1771 if (Array.isArray(this.VideoFiles)) {
1772 files = files.concat(this.VideoFiles)
1773 }
1774
1775 if (Array.isArray(this.VideoStreamingPlaylists)) {
1776 for (const p of this.VideoStreamingPlaylists) {
1777 if (Array.isArray(p.VideoFiles)) {
1778 files = files.concat(p.VideoFiles)
1779 }
1780 }
1781 }
1782
1783 return files
1784 }
1785
1786 probeMaxQualityFile () {
1787 const file = this.getMaxQualityFile()
1788 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1789
1790 return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
1791 const probe = await ffprobePromise(originalFilePath)
1792
1793 const { audioStream } = await getAudioStream(originalFilePath, probe)
1794 const hasAudio = await hasAudioStream(originalFilePath, probe)
1795 const fps = await getVideoStreamFPS(originalFilePath, probe)
1796
1797 return {
1798 audioStream,
1799 hasAudio,
1800 fps,
1801
1802 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1803 }
1804 })
1805 }
1806
1807 getDescriptionAPIPath () {
1808 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1809 }
1810
1811 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1812 if (!this.VideoStreamingPlaylists) return undefined
1813
1814 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1815 if (!playlist) return undefined
1816
1817 return playlist.withVideo(this)
1818 }
1819
1820 setHLSPlaylist (playlist: MStreamingPlaylist) {
1821 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1822
1823 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1824 this.VideoStreamingPlaylists = toAdd
1825 return
1826 }
1827
1828 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1829 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1830 .concat(toAdd)
1831 }
1832
1833 removeWebTorrentFile (videoFile: MVideoFile, isRedundancy = false) {
1834 const filePath = isRedundancy
1835 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1836 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1837
1838 const promises: Promise<any>[] = [ remove(filePath) ]
1839 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1840
1841 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1842 promises.push(removeWebTorrentObjectStorage(videoFile))
1843 }
1844
1845 return Promise.all(promises)
1846 }
1847
1848 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1849 const directoryPath = isRedundancy
1850 ? getHLSRedundancyDirectory(this)
1851 : getHLSDirectory(this)
1852
1853 await remove(directoryPath)
1854
1855 if (isRedundancy !== true) {
1856 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1857 streamingPlaylistWithFiles.Video = this
1858
1859 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1860 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1861 }
1862
1863 // Remove physical files and torrents
1864 await Promise.all(
1865 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1866 )
1867
1868 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1869 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1870 }
1871 }
1872 }
1873
1874 async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
1875 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename)
1876 await videoFile.removeTorrent()
1877 await remove(filePath)
1878
1879 const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
1880 await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
1881
1882 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1883 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
1884 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
1885 }
1886 }
1887
1888 async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
1889 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename)
1890 await remove(filePath)
1891
1892 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1893 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
1894 }
1895 }
1896
1897 isOutdated () {
1898 if (this.isOwned()) return false
1899
1900 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1901 }
1902
1903 hasPrivacyForFederation () {
1904 return isPrivacyForFederation(this.privacy)
1905 }
1906
1907 hasStateForFederation () {
1908 return isStateForFederation(this.state)
1909 }
1910
1911 isNewVideo (newPrivacy: VideoPrivacy) {
1912 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1913 }
1914
1915 setAsRefreshed (transaction?: Transaction) {
1916 return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
1917 }
1918
1919 // ---------------------------------------------------------------------------
1920
1921 requiresAuth (options: {
1922 urlParamId: string
1923 checkBlacklist: boolean
1924 }) {
1925 const { urlParamId, checkBlacklist } = options
1926
1927 if (this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL) {
1928 return true
1929 }
1930
1931 if (this.privacy === VideoPrivacy.UNLISTED) {
1932 if (urlParamId && !isUUIDValid(urlParamId)) return true
1933
1934 return false
1935 }
1936
1937 if (checkBlacklist && this.VideoBlacklist) return true
1938
1939 if (this.privacy !== VideoPrivacy.PUBLIC) {
1940 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
1941 }
1942
1943 return false
1944 }
1945
1946 hasPrivateStaticPath () {
1947 return isVideoInPrivateDirectory(this.privacy)
1948 }
1949
1950 // ---------------------------------------------------------------------------
1951
1952 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
1953 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
1954
1955 this.state = newState
1956
1957 if (this.state === VideoState.PUBLISHED && isNewVideo) {
1958 this.publishedAt = new Date()
1959 }
1960
1961 await this.save({ transaction })
1962 }
1963
1964 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1965 if (!this.duration) throw new Error(`Cannot get bandwidth bits because video ${this.url} has duration of 0`)
1966
1967 return Math.ceil((videoFile.size * 8) / this.duration)
1968 }
1969
1970 getTrackerUrls () {
1971 if (this.isOwned()) {
1972 return [
1973 WEBSERVER.URL + '/tracker/announce',
1974 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
1975 ]
1976 }
1977
1978 return this.Trackers.map(t => t.url)
1979 }
1980 }