]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
baa8c120a1ddee8eb7d9e7b50093a4376140b9f9
[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 { Hooks } from '@server/lib/plugins/hooks'
33 import { VideoPathManager } from '@server/lib/video-path-manager'
34 import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
35 import { getServerActor } from '@server/models/application/application'
36 import { ModelCache } from '@server/models/shared/model-cache'
37 import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
38 import { uuidToShort } from '@shared/extra-utils'
39 import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@shared/ffmpeg'
40 import {
41 ResultList,
42 ThumbnailType,
43 UserRight,
44 Video,
45 VideoDetails,
46 VideoFile,
47 VideoInclude,
48 VideoObject,
49 VideoPrivacy,
50 VideoRateType,
51 VideoState,
52 VideoStorage,
53 VideoStreamingPlaylistType
54 } from '@shared/models'
55 import { AttributesOnly } from '@shared/typescript-utils'
56 import { peertubeTruncate } from '../../helpers/core-utils'
57 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
58 import { exists, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
59 import {
60 isVideoDescriptionValid,
61 isVideoDurationValid,
62 isVideoNameValid,
63 isVideoPrivacyValid,
64 isVideoStateValid,
65 isVideoSupportValid
66 } from '../../helpers/custom-validators/videos'
67 import { logger } from '../../helpers/logger'
68 import { CONFIG } from '../../initializers/config'
69 import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
70 import { sendDeleteVideo } from '../../lib/activitypub/send'
71 import {
72 MChannel,
73 MChannelAccountDefault,
74 MChannelId,
75 MStreamingPlaylist,
76 MStreamingPlaylistFilesVideo,
77 MUserAccountId,
78 MUserId,
79 MVideo,
80 MVideoAccountLight,
81 MVideoAccountLightBlacklistAllFiles,
82 MVideoAP,
83 MVideoDetails,
84 MVideoFileVideo,
85 MVideoFormattable,
86 MVideoFormattableDetails,
87 MVideoForUser,
88 MVideoFullLight,
89 MVideoId,
90 MVideoImmutable,
91 MVideoThumbnail,
92 MVideoThumbnailBlacklist,
93 MVideoWithAllFiles,
94 MVideoWithFile
95 } from '../../types/models'
96 import { MThumbnail } from '../../types/models/video/thumbnail'
97 import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
98 import { VideoAbuseModel } from '../abuse/video-abuse'
99 import { AccountModel } from '../account/account'
100 import { AccountVideoRateModel } from '../account/account-video-rate'
101 import { ActorModel } from '../actor/actor'
102 import { ActorImageModel } from '../actor/actor-image'
103 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
104 import { ServerModel } from '../server/server'
105 import { TrackerModel } from '../server/tracker'
106 import { VideoTrackerModel } from '../server/video-tracker'
107 import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
108 import { UserModel } from '../user/user'
109 import { UserVideoHistoryModel } from '../user/user-video-history'
110 import { VideoViewModel } from '../view/video-view'
111 import {
112 videoFilesModelToFormattedJSON,
113 VideoFormattingJSONOptions,
114 videoModelToActivityPubObject,
115 videoModelToFormattedDetailsJSON,
116 videoModelToFormattedJSON
117 } from './formatter/video-format-utils'
118 import { ScheduleVideoUpdateModel } from './schedule-video-update'
119 import {
120 BuildVideosListQueryOptions,
121 DisplayOnlyForFollowerOptions,
122 VideoModelGetQueryBuilder,
123 VideosIdListQueryBuilder,
124 VideosModelListQueryBuilder
125 } from './sql/video'
126 import { TagModel } from './tag'
127 import { ThumbnailModel } from './thumbnail'
128 import { VideoBlacklistModel } from './video-blacklist'
129 import { VideoCaptionModel } from './video-caption'
130 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
131 import { VideoCommentModel } from './video-comment'
132 import { VideoFileModel } from './video-file'
133 import { VideoImportModel } from './video-import'
134 import { VideoJobInfoModel } from './video-job-info'
135 import { VideoLiveModel } from './video-live'
136 import { VideoPlaylistElementModel } from './video-playlist-element'
137 import { VideoShareModel } from './video-share'
138 import { VideoSourceModel } from './video-source'
139 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
140 import { VideoTagModel } from './video-tag'
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.uuid, 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 excludeAlreadyWatched?: boolean
1091 }) {
1092 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1093 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1094
1095 const trendingDays = options.sort.endsWith('trending')
1096 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1097 : undefined
1098
1099 let trendingAlgorithm: string
1100 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1101 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1102
1103 const serverActor = await getServerActor()
1104
1105 const queryOptions = {
1106 ...pick(options, [
1107 'start',
1108 'count',
1109 'sort',
1110 'nsfw',
1111 'isLive',
1112 'categoryOneOf',
1113 'licenceOneOf',
1114 'languageOneOf',
1115 'tagsOneOf',
1116 'tagsAllOf',
1117 'privacyOneOf',
1118 'isLocal',
1119 'include',
1120 'displayOnlyForFollower',
1121 'hasFiles',
1122 'accountId',
1123 'videoChannelId',
1124 'videoPlaylistId',
1125 'user',
1126 'historyOfUser',
1127 'hasHLSFiles',
1128 'hasWebtorrentFiles',
1129 'search',
1130 'excludeAlreadyWatched'
1131 ]),
1132
1133 serverAccountIdForBlock: serverActor.Account.id,
1134 trendingDays,
1135 trendingAlgorithm
1136 }
1137
1138 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1139 }
1140
1141 static async searchAndPopulateAccountAndServer (options: {
1142 start: number
1143 count: number
1144 sort: string
1145
1146 nsfw?: boolean
1147 isLive?: boolean
1148 isLocal?: boolean
1149 include?: VideoInclude
1150
1151 categoryOneOf?: number[]
1152 licenceOneOf?: number[]
1153 languageOneOf?: string[]
1154 tagsOneOf?: string[]
1155 tagsAllOf?: string[]
1156 privacyOneOf?: VideoPrivacy[]
1157
1158 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1159
1160 user?: MUserAccountId
1161
1162 hasWebtorrentFiles?: boolean
1163 hasHLSFiles?: boolean
1164
1165 search?: string
1166
1167 host?: string
1168 startDate?: string // ISO 8601
1169 endDate?: string // ISO 8601
1170 originallyPublishedStartDate?: string
1171 originallyPublishedEndDate?: string
1172
1173 durationMin?: number // seconds
1174 durationMax?: number // seconds
1175 uuids?: string[]
1176
1177 excludeAlreadyWatched?: boolean
1178 }) {
1179 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1180 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1181
1182 const serverActor = await getServerActor()
1183
1184 const queryOptions = {
1185 ...pick(options, [
1186 'include',
1187 'nsfw',
1188 'isLive',
1189 'categoryOneOf',
1190 'licenceOneOf',
1191 'languageOneOf',
1192 'tagsOneOf',
1193 'tagsAllOf',
1194 'privacyOneOf',
1195 'user',
1196 'isLocal',
1197 'host',
1198 'start',
1199 'count',
1200 'sort',
1201 'startDate',
1202 'endDate',
1203 'originallyPublishedStartDate',
1204 'originallyPublishedEndDate',
1205 'durationMin',
1206 'durationMax',
1207 'hasHLSFiles',
1208 'hasWebtorrentFiles',
1209 'uuids',
1210 'search',
1211 'displayOnlyForFollower',
1212 'excludeAlreadyWatched'
1213 ]),
1214 serverAccountIdForBlock: serverActor.Account.id
1215 }
1216
1217 return VideoModel.getAvailableForApi(queryOptions)
1218 }
1219
1220 static countLives (options: {
1221 remote: boolean
1222 mode: 'published' | 'not-ended'
1223 }) {
1224 const query = {
1225 where: {
1226 remote: options.remote,
1227 isLive: true,
1228 state: options.mode === 'not-ended'
1229 ? { [Op.ne]: VideoState.LIVE_ENDED }
1230 : { [Op.eq]: VideoState.PUBLISHED }
1231 }
1232 }
1233
1234 return VideoModel.count(query)
1235 }
1236
1237 static countVideosUploadedByUserSince (userId: number, since: Date) {
1238 const options = {
1239 include: [
1240 {
1241 model: VideoChannelModel.unscoped(),
1242 required: true,
1243 include: [
1244 {
1245 model: AccountModel.unscoped(),
1246 required: true,
1247 include: [
1248 {
1249 model: UserModel.unscoped(),
1250 required: true,
1251 where: {
1252 id: userId
1253 }
1254 }
1255 ]
1256 }
1257 ]
1258 }
1259 ],
1260 where: {
1261 createdAt: {
1262 [Op.gte]: since
1263 }
1264 }
1265 }
1266
1267 return VideoModel.unscoped().count(options)
1268 }
1269
1270 static countLivesOfAccount (accountId: number) {
1271 const options = {
1272 where: {
1273 remote: false,
1274 isLive: true,
1275 state: {
1276 [Op.ne]: VideoState.LIVE_ENDED
1277 }
1278 },
1279 include: [
1280 {
1281 required: true,
1282 model: VideoChannelModel.unscoped(),
1283 where: {
1284 accountId
1285 }
1286 }
1287 ]
1288 }
1289
1290 return VideoModel.count(options)
1291 }
1292
1293 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1294 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1295
1296 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1297 }
1298
1299 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1300 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1301
1302 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1303 }
1304
1305 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1306 const fun = () => {
1307 const query = {
1308 where: buildWhereIdOrUUID(id),
1309 transaction: t
1310 }
1311
1312 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1313 }
1314
1315 return ModelCache.Instance.doCache({
1316 cacheType: 'load-video-immutable-id',
1317 key: '' + id,
1318 deleteKey: 'video',
1319 fun
1320 })
1321 }
1322
1323 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1324 const fun = () => {
1325 const query: FindOptions = {
1326 where: {
1327 url
1328 },
1329 transaction
1330 }
1331
1332 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1333 }
1334
1335 return ModelCache.Instance.doCache({
1336 cacheType: 'load-video-immutable-url',
1337 key: url,
1338 deleteKey: 'video',
1339 fun
1340 })
1341 }
1342
1343 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1344 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1345
1346 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1347 }
1348
1349 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1350 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1351
1352 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1353 }
1354
1355 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1356 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1357
1358 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1359 }
1360
1361 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1362 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1363
1364 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1365 }
1366
1367 static loadFull (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1368 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1369
1370 return queryBuilder.queryVideo({ id, transaction: t, type: 'full', userId })
1371 }
1372
1373 static loadForGetAPI (parameters: {
1374 id: number | string
1375 transaction?: Transaction
1376 userId?: number
1377 }): Promise<MVideoDetails> {
1378 const { id, transaction, userId } = parameters
1379 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1380
1381 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1382 }
1383
1384 static async getStats () {
1385 const serverActor = await getServerActor()
1386
1387 let totalLocalVideoViews = await VideoModel.sum('views', {
1388 where: {
1389 remote: false
1390 }
1391 })
1392
1393 // Sequelize could return null...
1394 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1395
1396 const baseOptions = {
1397 start: 0,
1398 count: 0,
1399 sort: '-publishedAt',
1400 nsfw: null,
1401 displayOnlyForFollower: {
1402 actorId: serverActor.id,
1403 orLocalVideos: true
1404 }
1405 }
1406
1407 const { total: totalLocalVideos } = await VideoModel.listForApi({
1408 ...baseOptions,
1409
1410 isLocal: true
1411 })
1412
1413 const { total: totalVideos } = await VideoModel.listForApi(baseOptions)
1414
1415 return {
1416 totalLocalVideos,
1417 totalLocalVideoViews,
1418 totalVideos
1419 }
1420 }
1421
1422 static incrementViews (id: number, views: number) {
1423 return VideoModel.increment('views', {
1424 by: views,
1425 where: {
1426 id
1427 }
1428 })
1429 }
1430
1431 static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
1432 const field = type === 'like'
1433 ? 'likes'
1434 : 'dislikes'
1435
1436 const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
1437
1438 return AccountVideoRateModel.sequelize.query(rawQuery, {
1439 transaction: t,
1440 replacements: { videoId, rateType: type, count },
1441 type: QueryTypes.UPDATE
1442 })
1443 }
1444
1445 static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
1446 const field = type === 'like'
1447 ? 'likes'
1448 : 'dislikes'
1449
1450 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1451 '(' +
1452 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1453 ') ' +
1454 'WHERE "video"."id" = :videoId'
1455
1456 return AccountVideoRateModel.sequelize.query(rawQuery, {
1457 transaction: t,
1458 replacements: { videoId, rateType: type },
1459 type: QueryTypes.UPDATE
1460 })
1461 }
1462
1463 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1464 // Instances only share videos
1465 const query = 'SELECT 1 FROM "videoShare" ' +
1466 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1467 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1468 'UNION ' +
1469 'SELECT 1 FROM "video" ' +
1470 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
1471 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
1472 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "account"."actorId" ' +
1473 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "video"."id" = $videoId ' +
1474 'LIMIT 1'
1475
1476 const options = {
1477 type: QueryTypes.SELECT as QueryTypes.SELECT,
1478 bind: { followerActorId, videoId },
1479 raw: true
1480 }
1481
1482 return VideoModel.sequelize.query(query, options)
1483 .then(results => results.length === 1)
1484 }
1485
1486 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1487 const options = {
1488 where: {
1489 channelId: ofChannel.id
1490 },
1491 transaction: t
1492 }
1493
1494 return VideoModel.update({ support: ofChannel.support }, options)
1495 }
1496
1497 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1498 const query = {
1499 attributes: [ 'id' ],
1500 where: {
1501 channelId: videoChannel.id
1502 }
1503 }
1504
1505 return VideoModel.findAll(query)
1506 .then(videos => videos.map(v => v.id))
1507 }
1508
1509 // threshold corresponds to how many video the field should have to be returned
1510 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1511 const serverActor = await getServerActor()
1512
1513 const queryOptions: BuildVideosListQueryOptions = {
1514 attributes: [ `"${field}"` ],
1515 group: `GROUP BY "${field}"`,
1516 having: `HAVING COUNT("${field}") >= ${threshold}`,
1517 start: 0,
1518 sort: 'random',
1519 count,
1520 serverAccountIdForBlock: serverActor.Account.id,
1521 displayOnlyForFollower: {
1522 actorId: serverActor.id,
1523 orLocalVideos: true
1524 }
1525 }
1526
1527 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1528
1529 return queryBuilder.queryVideoIds(queryOptions)
1530 .then(rows => rows.map(r => r[field]))
1531 }
1532
1533 static buildTrendingQuery (trendingDays: number) {
1534 return {
1535 attributes: [],
1536 subQuery: false,
1537 model: VideoViewModel,
1538 required: false,
1539 where: {
1540 startDate: {
1541 // FIXME: ts error
1542 [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1543 }
1544 }
1545 }
1546 }
1547
1548 private static async getAvailableForApi (
1549 options: BuildVideosListQueryOptions,
1550 countVideos = true
1551 ): Promise<ResultList<VideoModel>> {
1552 const span = tracer.startSpan('peertube.VideoModel.getAvailableForApi')
1553
1554 function getCount () {
1555 if (countVideos !== true) return Promise.resolve(undefined)
1556
1557 const countOptions = Object.assign({}, options, { isCount: true })
1558 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1559
1560 return queryBuilder.countVideoIds(countOptions)
1561 }
1562
1563 function getModels () {
1564 if (options.count === 0) return Promise.resolve([])
1565
1566 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1567
1568 return queryBuilder.queryVideos(options)
1569 }
1570
1571 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1572
1573 span.end()
1574
1575 return {
1576 data: rows,
1577 total: count
1578 }
1579 }
1580
1581 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1582 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1583 throw new Error('Try to filter all-local but user cannot see all videos')
1584 }
1585 }
1586
1587 private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
1588 if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1589 throw new Error('Try to choose video privacies but user cannot see all videos')
1590 }
1591 }
1592
1593 private static isPrivateInclude (include: VideoInclude) {
1594 return include & VideoInclude.BLACKLISTED ||
1595 include & VideoInclude.BLOCKED_OWNER ||
1596 include & VideoInclude.NOT_PUBLISHED_STATE
1597 }
1598
1599 isBlacklisted () {
1600 return !!this.VideoBlacklist
1601 }
1602
1603 isBlocked () {
1604 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1605 }
1606
1607 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1608 const files = this.getAllFiles()
1609 const file = fun(files, file => file.resolution)
1610 if (!file) return undefined
1611
1612 if (file.videoId) {
1613 return Object.assign(file, { Video: this })
1614 }
1615
1616 if (file.videoStreamingPlaylistId) {
1617 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1618
1619 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1620 }
1621
1622 throw new Error('File is not associated to a video of a playlist')
1623 }
1624
1625 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1626 return this.getQualityFileBy(maxBy)
1627 }
1628
1629 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1630 return this.getQualityFileBy(minBy)
1631 }
1632
1633 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1634 if (Array.isArray(this.VideoFiles) === false) return undefined
1635
1636 const file = this.VideoFiles.find(f => f.resolution === resolution)
1637 if (!file) return undefined
1638
1639 return Object.assign(file, { Video: this })
1640 }
1641
1642 hasWebTorrentFiles () {
1643 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1644 }
1645
1646 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1647 thumbnail.videoId = this.id
1648
1649 const savedThumbnail = await thumbnail.save({ transaction })
1650
1651 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1652
1653 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1654 this.Thumbnails.push(savedThumbnail)
1655 }
1656
1657 getMiniature () {
1658 if (Array.isArray(this.Thumbnails) === false) return undefined
1659
1660 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1661 }
1662
1663 hasPreview () {
1664 return !!this.getPreview()
1665 }
1666
1667 getPreview () {
1668 if (Array.isArray(this.Thumbnails) === false) return undefined
1669
1670 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1671 }
1672
1673 isOwned () {
1674 return this.remote === false
1675 }
1676
1677 getWatchStaticPath () {
1678 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1679 }
1680
1681 getEmbedStaticPath () {
1682 return buildVideoEmbedPath(this)
1683 }
1684
1685 getMiniatureStaticPath () {
1686 const thumbnail = this.getMiniature()
1687 if (!thumbnail) return null
1688
1689 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1690 }
1691
1692 getPreviewStaticPath () {
1693 const preview = this.getPreview()
1694 if (!preview) return null
1695
1696 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1697 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1698 }
1699
1700 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1701 return videoModelToFormattedJSON(this, options)
1702 }
1703
1704 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1705 return videoModelToFormattedDetailsJSON(this)
1706 }
1707
1708 getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
1709 let files: VideoFile[] = []
1710
1711 if (Array.isArray(this.VideoFiles)) {
1712 const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
1713 files = files.concat(result)
1714 }
1715
1716 for (const p of (this.VideoStreamingPlaylists || [])) {
1717 const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet })
1718 files = files.concat(result)
1719 }
1720
1721 return files
1722 }
1723
1724 toActivityPubObject (this: MVideoAP): Promise<VideoObject> {
1725 return Hooks.wrapObject(
1726 videoModelToActivityPubObject(this),
1727 'filter:activity-pub.video.json-ld.build.result',
1728 { video: this }
1729 )
1730 }
1731
1732 getTruncatedDescription () {
1733 if (!this.description) return null
1734
1735 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1736 return peertubeTruncate(this.description, { length: maxLength })
1737 }
1738
1739 getAllFiles () {
1740 let files: MVideoFile[] = []
1741
1742 if (Array.isArray(this.VideoFiles)) {
1743 files = files.concat(this.VideoFiles)
1744 }
1745
1746 if (Array.isArray(this.VideoStreamingPlaylists)) {
1747 for (const p of this.VideoStreamingPlaylists) {
1748 if (Array.isArray(p.VideoFiles)) {
1749 files = files.concat(p.VideoFiles)
1750 }
1751 }
1752 }
1753
1754 return files
1755 }
1756
1757 probeMaxQualityFile () {
1758 const file = this.getMaxQualityFile()
1759 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1760
1761 return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
1762 const probe = await ffprobePromise(originalFilePath)
1763
1764 const { audioStream } = await getAudioStream(originalFilePath, probe)
1765 const hasAudio = await hasAudioStream(originalFilePath, probe)
1766 const fps = await getVideoStreamFPS(originalFilePath, probe)
1767
1768 return {
1769 audioStream,
1770 hasAudio,
1771 fps,
1772
1773 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1774 }
1775 })
1776 }
1777
1778 getDescriptionAPIPath () {
1779 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1780 }
1781
1782 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1783 if (!this.VideoStreamingPlaylists) return undefined
1784
1785 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1786 if (!playlist) return undefined
1787
1788 return playlist.withVideo(this)
1789 }
1790
1791 setHLSPlaylist (playlist: MStreamingPlaylist) {
1792 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1793
1794 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1795 this.VideoStreamingPlaylists = toAdd
1796 return
1797 }
1798
1799 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1800 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1801 .concat(toAdd)
1802 }
1803
1804 removeWebTorrentFile (videoFile: MVideoFile, isRedundancy = false) {
1805 const filePath = isRedundancy
1806 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1807 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1808
1809 const promises: Promise<any>[] = [ remove(filePath) ]
1810 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1811
1812 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1813 promises.push(removeWebTorrentObjectStorage(videoFile))
1814 }
1815
1816 return Promise.all(promises)
1817 }
1818
1819 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1820 const directoryPath = isRedundancy
1821 ? getHLSRedundancyDirectory(this)
1822 : getHLSDirectory(this)
1823
1824 await remove(directoryPath)
1825
1826 if (isRedundancy !== true) {
1827 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1828 streamingPlaylistWithFiles.Video = this
1829
1830 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1831 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1832 }
1833
1834 // Remove physical files and torrents
1835 await Promise.all(
1836 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1837 )
1838
1839 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1840 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1841 }
1842 }
1843 }
1844
1845 async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
1846 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename)
1847 await videoFile.removeTorrent()
1848 await remove(filePath)
1849
1850 const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
1851 await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
1852
1853 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1854 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
1855 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
1856 }
1857 }
1858
1859 async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
1860 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename)
1861 await remove(filePath)
1862
1863 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1864 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
1865 }
1866 }
1867
1868 isOutdated () {
1869 if (this.isOwned()) return false
1870
1871 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1872 }
1873
1874 hasPrivacyForFederation () {
1875 return isPrivacyForFederation(this.privacy)
1876 }
1877
1878 hasStateForFederation () {
1879 return isStateForFederation(this.state)
1880 }
1881
1882 isNewVideo (newPrivacy: VideoPrivacy) {
1883 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1884 }
1885
1886 setAsRefreshed (transaction?: Transaction) {
1887 return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
1888 }
1889
1890 // ---------------------------------------------------------------------------
1891
1892 requiresAuth (options: {
1893 urlParamId: string
1894 checkBlacklist: boolean
1895 }) {
1896 const { urlParamId, checkBlacklist } = options
1897
1898 if (this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL) {
1899 return true
1900 }
1901
1902 if (this.privacy === VideoPrivacy.UNLISTED) {
1903 if (urlParamId && !isUUIDValid(urlParamId)) return true
1904
1905 return false
1906 }
1907
1908 if (checkBlacklist && this.VideoBlacklist) return true
1909
1910 if (this.privacy !== VideoPrivacy.PUBLIC) {
1911 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
1912 }
1913
1914 return false
1915 }
1916
1917 hasPrivateStaticPath () {
1918 return isVideoInPrivateDirectory(this.privacy)
1919 }
1920
1921 // ---------------------------------------------------------------------------
1922
1923 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
1924 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
1925
1926 this.state = newState
1927
1928 if (this.state === VideoState.PUBLISHED && isNewVideo) {
1929 this.publishedAt = new Date()
1930 }
1931
1932 await this.save({ transaction })
1933 }
1934
1935 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1936 if (!this.duration) throw new Error(`Cannot get bandwidth bits because video ${this.url} has duration of 0`)
1937
1938 return Math.ceil((videoFile.size * 8) / this.duration)
1939 }
1940
1941 getTrackerUrls () {
1942 if (this.isOwned()) {
1943 return [
1944 WEBSERVER.URL + '/tracker/announce',
1945 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
1946 ]
1947 }
1948
1949 return this.Trackers.map(t => t.url)
1950 }
1951 }