]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Merge branch 'release/4.3.0' into develop
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
1 import Bluebird from 'bluebird'
2 import { remove } from 'fs-extra'
3 import { maxBy, minBy } from 'lodash'
4 import { join } from 'path'
5 import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
6 import {
7 AllowNull,
8 BeforeDestroy,
9 BelongsTo,
10 BelongsToMany,
11 Column,
12 CreatedAt,
13 DataType,
14 Default,
15 ForeignKey,
16 HasMany,
17 HasOne,
18 Is,
19 IsInt,
20 IsUUID,
21 Min,
22 Model,
23 Scopes,
24 Table,
25 UpdatedAt
26 } from 'sequelize-typescript'
27 import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
28 import { LiveManager } from '@server/lib/live/live-manager'
29 import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
30 import { tracer } from '@server/lib/opentelemetry/tracing'
31 import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
32 import { VideoPathManager } from '@server/lib/video-path-manager'
33 import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
34 import { getServerActor } from '@server/models/application/application'
35 import { ModelCache } from '@server/models/model-cache'
36 import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
37 import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils'
38 import {
39 ResultList,
40 ThumbnailType,
41 UserRight,
42 Video,
43 VideoDetails,
44 VideoFile,
45 VideoInclude,
46 VideoObject,
47 VideoPrivacy,
48 VideoRateType,
49 VideoState,
50 VideoStorage,
51 VideoStreamingPlaylistType
52 } from '@shared/models'
53 import { AttributesOnly } from '@shared/typescript-utils'
54 import { peertubeTruncate } from '../../helpers/core-utils'
55 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
56 import { exists, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
57 import {
58 isVideoDescriptionValid,
59 isVideoDurationValid,
60 isVideoNameValid,
61 isVideoPrivacyValid,
62 isVideoStateValid,
63 isVideoSupportValid
64 } from '../../helpers/custom-validators/videos'
65 import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
66 import { logger } from '../../helpers/logger'
67 import { CONFIG } from '../../initializers/config'
68 import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
69 import { sendDeleteVideo } from '../../lib/activitypub/send'
70 import {
71 MChannel,
72 MChannelAccountDefault,
73 MChannelId,
74 MStreamingPlaylist,
75 MStreamingPlaylistFilesVideo,
76 MUserAccountId,
77 MUserId,
78 MVideo,
79 MVideoAccountLight,
80 MVideoAccountLightBlacklistAllFiles,
81 MVideoAP,
82 MVideoDetails,
83 MVideoFileVideo,
84 MVideoFormattable,
85 MVideoFormattableDetails,
86 MVideoForUser,
87 MVideoFullLight,
88 MVideoId,
89 MVideoImmutable,
90 MVideoThumbnail,
91 MVideoThumbnailBlacklist,
92 MVideoWithAllFiles,
93 MVideoWithFile
94 } from '../../types/models'
95 import { MThumbnail } from '../../types/models/video/thumbnail'
96 import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
97 import { VideoAbuseModel } from '../abuse/video-abuse'
98 import { AccountModel } from '../account/account'
99 import { AccountVideoRateModel } from '../account/account-video-rate'
100 import { ActorModel } from '../actor/actor'
101 import { ActorImageModel } from '../actor/actor-image'
102 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
103 import { ServerModel } from '../server/server'
104 import { TrackerModel } from '../server/tracker'
105 import { VideoTrackerModel } from '../server/video-tracker'
106 import { setAsUpdated } from '../shared'
107 import { UserModel } from '../user/user'
108 import { UserVideoHistoryModel } from '../user/user-video-history'
109 import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
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 onDelete: 'cascade'
710 })
711 VideoLive: VideoLiveModel
712
713 @HasOne(() => VideoImportModel, {
714 foreignKey: {
715 name: 'videoId',
716 allowNull: true
717 },
718 onDelete: 'set null'
719 })
720 VideoImport: VideoImportModel
721
722 @HasMany(() => VideoCaptionModel, {
723 foreignKey: {
724 name: 'videoId',
725 allowNull: false
726 },
727 onDelete: 'cascade',
728 hooks: true,
729 ['separate' as any]: true
730 })
731 VideoCaptions: VideoCaptionModel[]
732
733 @HasOne(() => VideoJobInfoModel, {
734 foreignKey: {
735 name: 'videoId',
736 allowNull: false
737 },
738 onDelete: 'cascade'
739 })
740 VideoJobInfo: VideoJobInfoModel
741
742 @BeforeDestroy
743 static async sendDelete (instance: MVideoAccountLight, options) {
744 if (!instance.isOwned()) return undefined
745
746 // Lazy load channels
747 if (!instance.VideoChannel) {
748 instance.VideoChannel = await instance.$get('VideoChannel', {
749 include: [
750 ActorModel,
751 AccountModel
752 ],
753 transaction: options.transaction
754 }) as MChannelAccountDefault
755 }
756
757 return sendDeleteVideo(instance, options.transaction)
758 }
759
760 @BeforeDestroy
761 static async removeFiles (instance: VideoModel, options) {
762 const tasks: Promise<any>[] = []
763
764 logger.info('Removing files of video %s.', instance.url)
765
766 if (instance.isOwned()) {
767 if (!Array.isArray(instance.VideoFiles)) {
768 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
769 }
770
771 // Remove physical files and torrents
772 instance.VideoFiles.forEach(file => {
773 tasks.push(instance.removeWebTorrentFile(file))
774 })
775
776 // Remove playlists file
777 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
778 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
779 }
780
781 for (const p of instance.VideoStreamingPlaylists) {
782 tasks.push(instance.removeStreamingPlaylistFiles(p))
783 }
784 }
785
786 // Do not wait video deletion because we could be in a transaction
787 Promise.all(tasks)
788 .then(() => logger.info('Removed files of video %s.', instance.url))
789 .catch(err => logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err }))
790
791 return undefined
792 }
793
794 @BeforeDestroy
795 static stopLiveIfNeeded (instance: VideoModel) {
796 if (!instance.isLive) return
797
798 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
799
800 LiveManager.Instance.stopSessionOf(instance.id, null)
801 }
802
803 @BeforeDestroy
804 static invalidateCache (instance: VideoModel) {
805 ModelCache.Instance.invalidateCache('video', instance.id)
806 }
807
808 @BeforeDestroy
809 static async saveEssentialDataToAbuses (instance: VideoModel, options) {
810 const tasks: Promise<any>[] = []
811
812 if (!Array.isArray(instance.VideoAbuses)) {
813 instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
814
815 if (instance.VideoAbuses.length === 0) return undefined
816 }
817
818 logger.info('Saving video abuses details of video %s.', instance.url)
819
820 if (!instance.Trackers) instance.Trackers = await instance.$get('Trackers', { transaction: options.transaction })
821 const details = instance.toFormattedDetailsJSON()
822
823 for (const abuse of instance.VideoAbuses) {
824 abuse.deletedVideo = details
825 tasks.push(abuse.save({ transaction: options.transaction }))
826 }
827
828 await Promise.all(tasks)
829 }
830
831 static listLocalIds (): Promise<number[]> {
832 const query = {
833 attributes: [ 'id' ],
834 raw: true,
835 where: {
836 remote: false
837 }
838 }
839
840 return VideoModel.findAll(query)
841 .then(rows => rows.map(r => r.id))
842 }
843
844 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
845 function getRawQuery (select: string) {
846 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
847 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
848 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
849 'WHERE "Account"."actorId" = ' + actorId
850 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
851 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
852 'WHERE "VideoShare"."actorId" = ' + actorId
853
854 return `(${queryVideo}) UNION (${queryVideoShare})`
855 }
856
857 const rawQuery = getRawQuery('"Video"."id"')
858 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
859
860 const query = {
861 distinct: true,
862 offset: start,
863 limit: count,
864 order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ]),
865 where: {
866 id: {
867 [Op.in]: Sequelize.literal('(' + rawQuery + ')')
868 },
869 [Op.or]: getPrivaciesForFederation()
870 },
871 include: [
872 {
873 attributes: [ 'filename', 'language', 'fileUrl' ],
874 model: VideoCaptionModel.unscoped(),
875 required: false
876 },
877 {
878 attributes: [ 'id', 'url' ],
879 model: VideoShareModel.unscoped(),
880 required: false,
881 // We only want videos shared by this actor
882 where: {
883 [Op.and]: [
884 {
885 id: {
886 [Op.not]: null
887 }
888 },
889 {
890 actorId
891 }
892 ]
893 },
894 include: [
895 {
896 attributes: [ 'id', 'url' ],
897 model: ActorModel.unscoped()
898 }
899 ]
900 },
901 {
902 model: VideoChannelModel.unscoped(),
903 required: true,
904 include: [
905 {
906 attributes: [ 'name' ],
907 model: AccountModel.unscoped(),
908 required: true,
909 include: [
910 {
911 attributes: [ 'id', 'url', 'followersUrl' ],
912 model: ActorModel.unscoped(),
913 required: true
914 }
915 ]
916 },
917 {
918 attributes: [ 'id', 'url', 'followersUrl' ],
919 model: ActorModel.unscoped(),
920 required: true
921 }
922 ]
923 },
924 {
925 model: VideoStreamingPlaylistModel.unscoped(),
926 required: false,
927 include: [
928 {
929 model: VideoFileModel,
930 required: false
931 }
932 ]
933 },
934 VideoLiveModel.unscoped(),
935 VideoFileModel,
936 TagModel
937 ]
938 }
939
940 return Bluebird.all([
941 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
942 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
943 ]).then(([ rows, totals ]) => {
944 // totals: totalVideos + totalVideoShares
945 let totalVideos = 0
946 let totalVideoShares = 0
947 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
948 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
949
950 const total = totalVideos + totalVideoShares
951 return {
952 data: rows,
953 total
954 }
955 })
956 }
957
958 static async listPublishedLiveUUIDs () {
959 const options = {
960 attributes: [ 'uuid' ],
961 where: {
962 isLive: true,
963 remote: false,
964 state: VideoState.PUBLISHED
965 }
966 }
967
968 const result = await VideoModel.findAll(options)
969
970 return result.map(v => v.uuid)
971 }
972
973 static listUserVideosForApi (options: {
974 accountId: number
975 start: number
976 count: number
977 sort: string
978
979 channelId?: number
980 isLive?: boolean
981 search?: string
982 }) {
983 const { accountId, channelId, start, count, sort, search, isLive } = options
984
985 function buildBaseQuery (forCount: boolean): FindOptions {
986 const where: WhereOptions = {}
987
988 if (search) {
989 where.name = {
990 [Op.iLike]: '%' + search + '%'
991 }
992 }
993
994 if (exists(isLive)) {
995 where.isLive = isLive
996 }
997
998 const channelWhere = channelId
999 ? { id: channelId }
1000 : {}
1001
1002 const baseQuery = {
1003 offset: start,
1004 limit: count,
1005 where,
1006 order: getVideoSort(sort),
1007 include: [
1008 {
1009 model: forCount
1010 ? VideoChannelModel.unscoped()
1011 : VideoChannelModel,
1012 required: true,
1013 where: channelWhere,
1014 include: [
1015 {
1016 model: forCount
1017 ? AccountModel.unscoped()
1018 : AccountModel,
1019 where: {
1020 id: accountId
1021 },
1022 required: true
1023 }
1024 ]
1025 }
1026 ]
1027 }
1028
1029 return baseQuery
1030 }
1031
1032 const countQuery = buildBaseQuery(true)
1033 const findQuery = buildBaseQuery(false)
1034
1035 const findScopes: (string | ScopeOptions)[] = [
1036 ScopeNames.WITH_SCHEDULED_UPDATE,
1037 ScopeNames.WITH_BLACKLISTED,
1038 ScopeNames.WITH_THUMBNAILS
1039 ]
1040
1041 return Promise.all([
1042 VideoModel.count(countQuery),
1043 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1044 ]).then(([ count, rows ]) => {
1045 return {
1046 data: rows,
1047 total: count
1048 }
1049 })
1050 }
1051
1052 static async listForApi (options: {
1053 start: number
1054 count: number
1055 sort: string
1056
1057 nsfw: boolean
1058 isLive?: boolean
1059 isLocal?: boolean
1060 include?: VideoInclude
1061
1062 hasFiles?: boolean // default false
1063 hasWebtorrentFiles?: boolean
1064 hasHLSFiles?: boolean
1065
1066 categoryOneOf?: number[]
1067 licenceOneOf?: number[]
1068 languageOneOf?: string[]
1069 tagsOneOf?: string[]
1070 tagsAllOf?: string[]
1071 privacyOneOf?: VideoPrivacy[]
1072
1073 accountId?: number
1074 videoChannelId?: number
1075
1076 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1077
1078 videoPlaylistId?: number
1079
1080 trendingDays?: number
1081
1082 user?: MUserAccountId
1083 historyOfUser?: MUserId
1084
1085 countVideos?: boolean
1086
1087 search?: string
1088 }) {
1089 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1090 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1091
1092 const trendingDays = options.sort.endsWith('trending')
1093 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1094 : undefined
1095
1096 let trendingAlgorithm: string
1097 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1098 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1099
1100 const serverActor = await getServerActor()
1101
1102 const queryOptions = {
1103 ...pick(options, [
1104 'start',
1105 'count',
1106 'sort',
1107 'nsfw',
1108 'isLive',
1109 'categoryOneOf',
1110 'licenceOneOf',
1111 'languageOneOf',
1112 'tagsOneOf',
1113 'tagsAllOf',
1114 'privacyOneOf',
1115 'isLocal',
1116 'include',
1117 'displayOnlyForFollower',
1118 'hasFiles',
1119 'accountId',
1120 'videoChannelId',
1121 'videoPlaylistId',
1122 'user',
1123 'historyOfUser',
1124 'hasHLSFiles',
1125 'hasWebtorrentFiles',
1126 'search'
1127 ]),
1128
1129 serverAccountIdForBlock: serverActor.Account.id,
1130 trendingDays,
1131 trendingAlgorithm
1132 }
1133
1134 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1135 }
1136
1137 static async searchAndPopulateAccountAndServer (options: {
1138 start: number
1139 count: number
1140 sort: string
1141
1142 nsfw?: boolean
1143 isLive?: boolean
1144 isLocal?: boolean
1145 include?: VideoInclude
1146
1147 categoryOneOf?: number[]
1148 licenceOneOf?: number[]
1149 languageOneOf?: string[]
1150 tagsOneOf?: string[]
1151 tagsAllOf?: string[]
1152 privacyOneOf?: VideoPrivacy[]
1153
1154 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1155
1156 user?: MUserAccountId
1157
1158 hasWebtorrentFiles?: boolean
1159 hasHLSFiles?: boolean
1160
1161 search?: string
1162
1163 host?: string
1164 startDate?: string // ISO 8601
1165 endDate?: string // ISO 8601
1166 originallyPublishedStartDate?: string
1167 originallyPublishedEndDate?: string
1168
1169 durationMin?: number // seconds
1170 durationMax?: number // seconds
1171 uuids?: string[]
1172 }) {
1173 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1174 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1175
1176 const serverActor = await getServerActor()
1177
1178 const queryOptions = {
1179 ...pick(options, [
1180 'include',
1181 'nsfw',
1182 'isLive',
1183 'categoryOneOf',
1184 'licenceOneOf',
1185 'languageOneOf',
1186 'tagsOneOf',
1187 'tagsAllOf',
1188 'privacyOneOf',
1189 'user',
1190 'isLocal',
1191 'host',
1192 'start',
1193 'count',
1194 'sort',
1195 'startDate',
1196 'endDate',
1197 'originallyPublishedStartDate',
1198 'originallyPublishedEndDate',
1199 'durationMin',
1200 'durationMax',
1201 'hasHLSFiles',
1202 'hasWebtorrentFiles',
1203 'uuids',
1204 'search',
1205 'displayOnlyForFollower'
1206 ]),
1207 serverAccountIdForBlock: serverActor.Account.id
1208 }
1209
1210 return VideoModel.getAvailableForApi(queryOptions)
1211 }
1212
1213 static countLives (options: {
1214 remote: boolean
1215 mode: 'published' | 'not-ended'
1216 }) {
1217 const query = {
1218 where: {
1219 remote: options.remote,
1220 isLive: true,
1221 state: options.mode === 'not-ended'
1222 ? { [Op.ne]: VideoState.LIVE_ENDED }
1223 : { [Op.eq]: VideoState.PUBLISHED }
1224 }
1225 }
1226
1227 return VideoModel.count(query)
1228 }
1229
1230 static countVideosUploadedByUserSince (userId: number, since: Date) {
1231 const options = {
1232 include: [
1233 {
1234 model: VideoChannelModel.unscoped(),
1235 required: true,
1236 include: [
1237 {
1238 model: AccountModel.unscoped(),
1239 required: true,
1240 include: [
1241 {
1242 model: UserModel.unscoped(),
1243 required: true,
1244 where: {
1245 id: userId
1246 }
1247 }
1248 ]
1249 }
1250 ]
1251 }
1252 ],
1253 where: {
1254 createdAt: {
1255 [Op.gte]: since
1256 }
1257 }
1258 }
1259
1260 return VideoModel.unscoped().count(options)
1261 }
1262
1263 static countLivesOfAccount (accountId: number) {
1264 const options = {
1265 where: {
1266 remote: false,
1267 isLive: true,
1268 state: {
1269 [Op.ne]: VideoState.LIVE_ENDED
1270 }
1271 },
1272 include: [
1273 {
1274 required: true,
1275 model: VideoChannelModel.unscoped(),
1276 where: {
1277 accountId
1278 }
1279 }
1280 ]
1281 }
1282
1283 return VideoModel.count(options)
1284 }
1285
1286 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1287 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1288
1289 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1290 }
1291
1292 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1293 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1294
1295 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1296 }
1297
1298 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1299 const fun = () => {
1300 const query = {
1301 where: buildWhereIdOrUUID(id),
1302 transaction: t
1303 }
1304
1305 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1306 }
1307
1308 return ModelCache.Instance.doCache({
1309 cacheType: 'load-video-immutable-id',
1310 key: '' + id,
1311 deleteKey: 'video',
1312 fun
1313 })
1314 }
1315
1316 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1317 const fun = () => {
1318 const query: FindOptions = {
1319 where: {
1320 url
1321 },
1322 transaction
1323 }
1324
1325 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1326 }
1327
1328 return ModelCache.Instance.doCache({
1329 cacheType: 'load-video-immutable-url',
1330 key: url,
1331 deleteKey: 'video',
1332 fun
1333 })
1334 }
1335
1336 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1337 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1338
1339 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1340 }
1341
1342 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1343 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1344
1345 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1346 }
1347
1348 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1349 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1350
1351 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1352 }
1353
1354 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1355 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1356
1357 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1358 }
1359
1360 static loadFull (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1361 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1362
1363 return queryBuilder.queryVideo({ id, transaction: t, type: 'full', userId })
1364 }
1365
1366 static loadForGetAPI (parameters: {
1367 id: number | string
1368 transaction?: Transaction
1369 userId?: number
1370 }): Promise<MVideoDetails> {
1371 const { id, transaction, userId } = parameters
1372 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1373
1374 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1375 }
1376
1377 static async getStats () {
1378 const serverActor = await getServerActor()
1379
1380 let totalLocalVideoViews = await VideoModel.sum('views', {
1381 where: {
1382 remote: false
1383 }
1384 })
1385
1386 // Sequelize could return null...
1387 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1388
1389 const baseOptions = {
1390 start: 0,
1391 count: 0,
1392 sort: '-publishedAt',
1393 nsfw: null,
1394 displayOnlyForFollower: {
1395 actorId: serverActor.id,
1396 orLocalVideos: true
1397 }
1398 }
1399
1400 const { total: totalLocalVideos } = await VideoModel.listForApi({
1401 ...baseOptions,
1402
1403 isLocal: true
1404 })
1405
1406 const { total: totalVideos } = await VideoModel.listForApi(baseOptions)
1407
1408 return {
1409 totalLocalVideos,
1410 totalLocalVideoViews,
1411 totalVideos
1412 }
1413 }
1414
1415 static incrementViews (id: number, views: number) {
1416 return VideoModel.increment('views', {
1417 by: views,
1418 where: {
1419 id
1420 }
1421 })
1422 }
1423
1424 static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
1425 const field = type === 'like'
1426 ? 'likes'
1427 : 'dislikes'
1428
1429 const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
1430
1431 return AccountVideoRateModel.sequelize.query(rawQuery, {
1432 transaction: t,
1433 replacements: { videoId, rateType: type, count },
1434 type: QueryTypes.UPDATE
1435 })
1436 }
1437
1438 static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
1439 const field = type === 'like'
1440 ? 'likes'
1441 : 'dislikes'
1442
1443 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1444 '(' +
1445 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1446 ') ' +
1447 'WHERE "video"."id" = :videoId'
1448
1449 return AccountVideoRateModel.sequelize.query(rawQuery, {
1450 transaction: t,
1451 replacements: { videoId, rateType: type },
1452 type: QueryTypes.UPDATE
1453 })
1454 }
1455
1456 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1457 // Instances only share videos
1458 const query = 'SELECT 1 FROM "videoShare" ' +
1459 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1460 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1461 'UNION ' +
1462 'SELECT 1 FROM "video" ' +
1463 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
1464 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
1465 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "account"."actorId" ' +
1466 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "video"."id" = $videoId ' +
1467 'LIMIT 1'
1468
1469 const options = {
1470 type: QueryTypes.SELECT as QueryTypes.SELECT,
1471 bind: { followerActorId, videoId },
1472 raw: true
1473 }
1474
1475 return VideoModel.sequelize.query(query, options)
1476 .then(results => results.length === 1)
1477 }
1478
1479 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1480 const options = {
1481 where: {
1482 channelId: ofChannel.id
1483 },
1484 transaction: t
1485 }
1486
1487 return VideoModel.update({ support: ofChannel.support }, options)
1488 }
1489
1490 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1491 const query = {
1492 attributes: [ 'id' ],
1493 where: {
1494 channelId: videoChannel.id
1495 }
1496 }
1497
1498 return VideoModel.findAll(query)
1499 .then(videos => videos.map(v => v.id))
1500 }
1501
1502 // threshold corresponds to how many video the field should have to be returned
1503 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1504 const serverActor = await getServerActor()
1505
1506 const queryOptions: BuildVideosListQueryOptions = {
1507 attributes: [ `"${field}"` ],
1508 group: `GROUP BY "${field}"`,
1509 having: `HAVING COUNT("${field}") >= ${threshold}`,
1510 start: 0,
1511 sort: 'random',
1512 count,
1513 serverAccountIdForBlock: serverActor.Account.id,
1514 displayOnlyForFollower: {
1515 actorId: serverActor.id,
1516 orLocalVideos: true
1517 }
1518 }
1519
1520 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1521
1522 return queryBuilder.queryVideoIds(queryOptions)
1523 .then(rows => rows.map(r => r[field]))
1524 }
1525
1526 static buildTrendingQuery (trendingDays: number) {
1527 return {
1528 attributes: [],
1529 subQuery: false,
1530 model: VideoViewModel,
1531 required: false,
1532 where: {
1533 startDate: {
1534 // FIXME: ts error
1535 [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1536 }
1537 }
1538 }
1539 }
1540
1541 private static async getAvailableForApi (
1542 options: BuildVideosListQueryOptions,
1543 countVideos = true
1544 ): Promise<ResultList<VideoModel>> {
1545 const span = tracer.startSpan('peertube.VideoModel.getAvailableForApi')
1546
1547 function getCount () {
1548 if (countVideos !== true) return Promise.resolve(undefined)
1549
1550 const countOptions = Object.assign({}, options, { isCount: true })
1551 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1552
1553 return queryBuilder.countVideoIds(countOptions)
1554 }
1555
1556 function getModels () {
1557 if (options.count === 0) return Promise.resolve([])
1558
1559 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1560
1561 return queryBuilder.queryVideos(options)
1562 }
1563
1564 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1565
1566 span.end()
1567
1568 return {
1569 data: rows,
1570 total: count
1571 }
1572 }
1573
1574 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1575 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1576 throw new Error('Try to filter all-local but user cannot see all videos')
1577 }
1578 }
1579
1580 private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
1581 if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1582 throw new Error('Try to choose video privacies but user cannot see all videos')
1583 }
1584 }
1585
1586 private static isPrivateInclude (include: VideoInclude) {
1587 return include & VideoInclude.BLACKLISTED ||
1588 include & VideoInclude.BLOCKED_OWNER ||
1589 include & VideoInclude.NOT_PUBLISHED_STATE
1590 }
1591
1592 isBlacklisted () {
1593 return !!this.VideoBlacklist
1594 }
1595
1596 isBlocked () {
1597 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1598 }
1599
1600 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1601 const files = this.getAllFiles()
1602 const file = fun(files, file => file.resolution)
1603 if (!file) return undefined
1604
1605 if (file.videoId) {
1606 return Object.assign(file, { Video: this })
1607 }
1608
1609 if (file.videoStreamingPlaylistId) {
1610 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1611
1612 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1613 }
1614
1615 throw new Error('File is not associated to a video of a playlist')
1616 }
1617
1618 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1619 return this.getQualityFileBy(maxBy)
1620 }
1621
1622 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1623 return this.getQualityFileBy(minBy)
1624 }
1625
1626 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1627 if (Array.isArray(this.VideoFiles) === false) return undefined
1628
1629 const file = this.VideoFiles.find(f => f.resolution === resolution)
1630 if (!file) return undefined
1631
1632 return Object.assign(file, { Video: this })
1633 }
1634
1635 hasWebTorrentFiles () {
1636 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1637 }
1638
1639 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1640 thumbnail.videoId = this.id
1641
1642 const savedThumbnail = await thumbnail.save({ transaction })
1643
1644 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1645
1646 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1647 this.Thumbnails.push(savedThumbnail)
1648 }
1649
1650 getMiniature () {
1651 if (Array.isArray(this.Thumbnails) === false) return undefined
1652
1653 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1654 }
1655
1656 hasPreview () {
1657 return !!this.getPreview()
1658 }
1659
1660 getPreview () {
1661 if (Array.isArray(this.Thumbnails) === false) return undefined
1662
1663 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1664 }
1665
1666 isOwned () {
1667 return this.remote === false
1668 }
1669
1670 getWatchStaticPath () {
1671 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1672 }
1673
1674 getEmbedStaticPath () {
1675 return buildVideoEmbedPath(this)
1676 }
1677
1678 getMiniatureStaticPath () {
1679 const thumbnail = this.getMiniature()
1680 if (!thumbnail) return null
1681
1682 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1683 }
1684
1685 getPreviewStaticPath () {
1686 const preview = this.getPreview()
1687 if (!preview) return null
1688
1689 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1690 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1691 }
1692
1693 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1694 return videoModelToFormattedJSON(this, options)
1695 }
1696
1697 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1698 return videoModelToFormattedDetailsJSON(this)
1699 }
1700
1701 getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
1702 let files: VideoFile[] = []
1703
1704 if (Array.isArray(this.VideoFiles)) {
1705 const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
1706 files = files.concat(result)
1707 }
1708
1709 for (const p of (this.VideoStreamingPlaylists || [])) {
1710 const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet })
1711 files = files.concat(result)
1712 }
1713
1714 return files
1715 }
1716
1717 toActivityPubObject (this: MVideoAP): VideoObject {
1718 return videoModelToActivityPubObject(this)
1719 }
1720
1721 getTruncatedDescription () {
1722 if (!this.description) return null
1723
1724 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1725 return peertubeTruncate(this.description, { length: maxLength })
1726 }
1727
1728 getAllFiles () {
1729 let files: MVideoFile[] = []
1730
1731 if (Array.isArray(this.VideoFiles)) {
1732 files = files.concat(this.VideoFiles)
1733 }
1734
1735 if (Array.isArray(this.VideoStreamingPlaylists)) {
1736 for (const p of this.VideoStreamingPlaylists) {
1737 if (Array.isArray(p.VideoFiles)) {
1738 files = files.concat(p.VideoFiles)
1739 }
1740 }
1741 }
1742
1743 return files
1744 }
1745
1746 probeMaxQualityFile () {
1747 const file = this.getMaxQualityFile()
1748 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1749
1750 return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
1751 const probe = await ffprobePromise(originalFilePath)
1752
1753 const { audioStream } = await getAudioStream(originalFilePath, probe)
1754 const hasAudio = await hasAudioStream(originalFilePath, probe)
1755
1756 return {
1757 audioStream,
1758 hasAudio,
1759
1760 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1761 }
1762 })
1763 }
1764
1765 getDescriptionAPIPath () {
1766 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1767 }
1768
1769 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1770 if (!this.VideoStreamingPlaylists) return undefined
1771
1772 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1773 if (!playlist) return undefined
1774
1775 return playlist.withVideo(this)
1776 }
1777
1778 setHLSPlaylist (playlist: MStreamingPlaylist) {
1779 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1780
1781 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1782 this.VideoStreamingPlaylists = toAdd
1783 return
1784 }
1785
1786 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1787 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1788 .concat(toAdd)
1789 }
1790
1791 removeWebTorrentFile (videoFile: MVideoFile, isRedundancy = false) {
1792 const filePath = isRedundancy
1793 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1794 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1795
1796 const promises: Promise<any>[] = [ remove(filePath) ]
1797 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1798
1799 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1800 promises.push(removeWebTorrentObjectStorage(videoFile))
1801 }
1802
1803 return Promise.all(promises)
1804 }
1805
1806 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1807 const directoryPath = isRedundancy
1808 ? getHLSRedundancyDirectory(this)
1809 : getHLSDirectory(this)
1810
1811 await remove(directoryPath)
1812
1813 if (isRedundancy !== true) {
1814 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1815 streamingPlaylistWithFiles.Video = this
1816
1817 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1818 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1819 }
1820
1821 // Remove physical files and torrents
1822 await Promise.all(
1823 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1824 )
1825
1826 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1827 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1828 }
1829 }
1830 }
1831
1832 async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
1833 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename)
1834 await videoFile.removeTorrent()
1835 await remove(filePath)
1836
1837 const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
1838 await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
1839
1840 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1841 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
1842 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
1843 }
1844 }
1845
1846 async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
1847 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename)
1848 await remove(filePath)
1849
1850 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1851 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
1852 }
1853 }
1854
1855 isOutdated () {
1856 if (this.isOwned()) return false
1857
1858 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1859 }
1860
1861 hasPrivacyForFederation () {
1862 return isPrivacyForFederation(this.privacy)
1863 }
1864
1865 hasStateForFederation () {
1866 return isStateForFederation(this.state)
1867 }
1868
1869 isNewVideo (newPrivacy: VideoPrivacy) {
1870 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1871 }
1872
1873 setAsRefreshed (transaction?: Transaction) {
1874 return setAsUpdated('video', this.id, transaction)
1875 }
1876
1877 // ---------------------------------------------------------------------------
1878
1879 requiresAuth (options: {
1880 urlParamId: string
1881 checkBlacklist: boolean
1882 }) {
1883 const { urlParamId, checkBlacklist } = options
1884
1885 if (this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL) {
1886 return true
1887 }
1888
1889 if (this.privacy === VideoPrivacy.UNLISTED) {
1890 if (urlParamId && !isUUIDValid(urlParamId)) return true
1891
1892 return false
1893 }
1894
1895 if (checkBlacklist && this.VideoBlacklist) return true
1896
1897 if (this.privacy !== VideoPrivacy.PUBLIC) {
1898 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
1899 }
1900
1901 return false
1902 }
1903
1904 hasPrivateStaticPath () {
1905 return isVideoInPrivateDirectory(this.privacy)
1906 }
1907
1908 // ---------------------------------------------------------------------------
1909
1910 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
1911 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
1912
1913 this.state = newState
1914
1915 if (this.state === VideoState.PUBLISHED && isNewVideo) {
1916 this.publishedAt = new Date()
1917 }
1918
1919 await this.save({ transaction })
1920 }
1921
1922 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1923 if (!this.duration) throw new Error(`Cannot get bandwidth bits because video ${this.url} has duration of 0`)
1924
1925 return Math.ceil((videoFile.size * 8) / this.duration)
1926 }
1927
1928 getTrackerUrls () {
1929 if (this.isOwned()) {
1930 return [
1931 WEBSERVER.URL + '/tracker/announce',
1932 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
1933 ]
1934 }
1935
1936 return this.Trackers.map(t => t.url)
1937 }
1938 }