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