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