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