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