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