]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Merge branch 'release/4.2.0' into develop
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
1 import Bluebird from 'bluebird'
2 import { remove } from 'fs-extra'
3 import { maxBy, minBy } from 'lodash'
4 import { join } from 'path'
5 import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
6 import {
7 AllowNull,
8 BeforeDestroy,
9 BelongsTo,
10 BelongsToMany,
11 Column,
12 CreatedAt,
13 DataType,
14 Default,
15 ForeignKey,
16 HasMany,
17 HasOne,
18 Is,
19 IsInt,
20 IsUUID,
21 Min,
22 Model,
23 Scopes,
24 Table,
25 UpdatedAt
26 } from 'sequelize-typescript'
27 import { 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 { VideoViewModel } from '../view/video-view'
110 import {
111 videoFilesModelToFormattedJSON,
112 VideoFormattingJSONOptions,
113 videoModelToActivityPubObject,
114 videoModelToFormattedDetailsJSON,
115 videoModelToFormattedJSON
116 } from './formatter/video-format-utils'
117 import { ScheduleVideoUpdateModel } from './schedule-video-update'
118 import {
119 BuildVideosListQueryOptions,
120 DisplayOnlyForFollowerOptions,
121 VideoModelGetQueryBuilder,
122 VideosIdListQueryBuilder,
123 VideosModelListQueryBuilder
124 } from './sql/video'
125 import { TagModel } from './tag'
126 import { ThumbnailModel } from './thumbnail'
127 import { VideoBlacklistModel } from './video-blacklist'
128 import { VideoCaptionModel } from './video-caption'
129 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
130 import { VideoCommentModel } from './video-comment'
131 import { VideoFileModel } from './video-file'
132 import { VideoImportModel } from './video-import'
133 import { VideoJobInfoModel } from './video-job-info'
134 import { VideoLiveModel } from './video-live'
135 import { VideoPlaylistElementModel } from './video-playlist-element'
136 import { VideoShareModel } from './video-share'
137 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
138 import { VideoTagModel } from './video-tag'
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, null)
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 (forCount: boolean): 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: forCount
1005 ? AccountModel.unscoped()
1006 : AccountModel,
1007 where: {
1008 id: accountId
1009 },
1010 required: true
1011 }
1012 ]
1013 }
1014 ]
1015 }
1016
1017 return baseQuery
1018 }
1019
1020 const countQuery = buildBaseQuery(true)
1021 const findQuery = buildBaseQuery(false)
1022
1023 const findScopes: (string | ScopeOptions)[] = [
1024 ScopeNames.WITH_SCHEDULED_UPDATE,
1025 ScopeNames.WITH_BLACKLISTED,
1026 ScopeNames.WITH_THUMBNAILS
1027 ]
1028
1029 return Promise.all([
1030 VideoModel.count(countQuery),
1031 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1032 ]).then(([ count, rows ]) => {
1033 return {
1034 data: rows,
1035 total: count
1036 }
1037 })
1038 }
1039
1040 static async listForApi (options: {
1041 start: number
1042 count: number
1043 sort: string
1044
1045 nsfw: boolean
1046 isLive?: boolean
1047 isLocal?: boolean
1048 include?: VideoInclude
1049
1050 hasFiles?: boolean // default false
1051 hasWebtorrentFiles?: boolean
1052 hasHLSFiles?: boolean
1053
1054 categoryOneOf?: number[]
1055 licenceOneOf?: number[]
1056 languageOneOf?: string[]
1057 tagsOneOf?: string[]
1058 tagsAllOf?: string[]
1059 privacyOneOf?: VideoPrivacy[]
1060
1061 accountId?: number
1062 videoChannelId?: number
1063
1064 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1065
1066 videoPlaylistId?: number
1067
1068 trendingDays?: number
1069
1070 user?: MUserAccountId
1071 historyOfUser?: MUserId
1072
1073 countVideos?: boolean
1074
1075 search?: string
1076 }) {
1077 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1078 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1079
1080 const trendingDays = options.sort.endsWith('trending')
1081 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1082 : undefined
1083
1084 let trendingAlgorithm: string
1085 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1086 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1087
1088 const serverActor = await getServerActor()
1089
1090 const queryOptions = {
1091 ...pick(options, [
1092 'start',
1093 'count',
1094 'sort',
1095 'nsfw',
1096 'isLive',
1097 'categoryOneOf',
1098 'licenceOneOf',
1099 'languageOneOf',
1100 'tagsOneOf',
1101 'tagsAllOf',
1102 'privacyOneOf',
1103 'isLocal',
1104 'include',
1105 'displayOnlyForFollower',
1106 'hasFiles',
1107 'accountId',
1108 'videoChannelId',
1109 'videoPlaylistId',
1110 'user',
1111 'historyOfUser',
1112 'hasHLSFiles',
1113 'hasWebtorrentFiles',
1114 'search'
1115 ]),
1116
1117 serverAccountIdForBlock: serverActor.Account.id,
1118 trendingDays,
1119 trendingAlgorithm
1120 }
1121
1122 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1123 }
1124
1125 static async searchAndPopulateAccountAndServer (options: {
1126 start: number
1127 count: number
1128 sort: string
1129
1130 nsfw?: boolean
1131 isLive?: boolean
1132 isLocal?: boolean
1133 include?: VideoInclude
1134
1135 categoryOneOf?: number[]
1136 licenceOneOf?: number[]
1137 languageOneOf?: string[]
1138 tagsOneOf?: string[]
1139 tagsAllOf?: string[]
1140 privacyOneOf?: VideoPrivacy[]
1141
1142 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1143
1144 user?: MUserAccountId
1145
1146 hasWebtorrentFiles?: boolean
1147 hasHLSFiles?: boolean
1148
1149 search?: string
1150
1151 host?: string
1152 startDate?: string // ISO 8601
1153 endDate?: string // ISO 8601
1154 originallyPublishedStartDate?: string
1155 originallyPublishedEndDate?: string
1156
1157 durationMin?: number // seconds
1158 durationMax?: number // seconds
1159 uuids?: string[]
1160 }) {
1161 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1162 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1163
1164 const serverActor = await getServerActor()
1165
1166 const queryOptions = {
1167 ...pick(options, [
1168 'include',
1169 'nsfw',
1170 'isLive',
1171 'categoryOneOf',
1172 'licenceOneOf',
1173 'languageOneOf',
1174 'tagsOneOf',
1175 'tagsAllOf',
1176 'privacyOneOf',
1177 'user',
1178 'isLocal',
1179 'host',
1180 'start',
1181 'count',
1182 'sort',
1183 'startDate',
1184 'endDate',
1185 'originallyPublishedStartDate',
1186 'originallyPublishedEndDate',
1187 'durationMin',
1188 'durationMax',
1189 'hasHLSFiles',
1190 'hasWebtorrentFiles',
1191 'uuids',
1192 'search',
1193 'displayOnlyForFollower'
1194 ]),
1195 serverAccountIdForBlock: serverActor.Account.id
1196 }
1197
1198 return VideoModel.getAvailableForApi(queryOptions)
1199 }
1200
1201 static countLocalLives () {
1202 const options = {
1203 where: {
1204 remote: false,
1205 isLive: true,
1206 state: {
1207 [Op.ne]: VideoState.LIVE_ENDED
1208 }
1209 }
1210 }
1211
1212 return VideoModel.count(options)
1213 }
1214
1215 static countVideosUploadedByUserSince (userId: number, since: Date) {
1216 const options = {
1217 include: [
1218 {
1219 model: VideoChannelModel.unscoped(),
1220 required: true,
1221 include: [
1222 {
1223 model: AccountModel.unscoped(),
1224 required: true,
1225 include: [
1226 {
1227 model: UserModel.unscoped(),
1228 required: true,
1229 where: {
1230 id: userId
1231 }
1232 }
1233 ]
1234 }
1235 ]
1236 }
1237 ],
1238 where: {
1239 createdAt: {
1240 [Op.gte]: since
1241 }
1242 }
1243 }
1244
1245 return VideoModel.unscoped().count(options)
1246 }
1247
1248 static countLivesOfAccount (accountId: number) {
1249 const options = {
1250 where: {
1251 remote: false,
1252 isLive: true,
1253 state: {
1254 [Op.ne]: VideoState.LIVE_ENDED
1255 }
1256 },
1257 include: [
1258 {
1259 required: true,
1260 model: VideoChannelModel.unscoped(),
1261 where: {
1262 accountId
1263 }
1264 }
1265 ]
1266 }
1267
1268 return VideoModel.count(options)
1269 }
1270
1271 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1272 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1273
1274 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1275 }
1276
1277 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1278 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1279
1280 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1281 }
1282
1283 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1284 const fun = () => {
1285 const query = {
1286 where: buildWhereIdOrUUID(id),
1287 transaction: t
1288 }
1289
1290 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1291 }
1292
1293 return ModelCache.Instance.doCache({
1294 cacheType: 'load-video-immutable-id',
1295 key: '' + id,
1296 deleteKey: 'video',
1297 fun
1298 })
1299 }
1300
1301 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1302 const fun = () => {
1303 const query: FindOptions = {
1304 where: {
1305 url
1306 },
1307 transaction
1308 }
1309
1310 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1311 }
1312
1313 return ModelCache.Instance.doCache({
1314 cacheType: 'load-video-immutable-url',
1315 key: url,
1316 deleteKey: 'video',
1317 fun
1318 })
1319 }
1320
1321 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1322 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1323
1324 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1325 }
1326
1327 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1328 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1329
1330 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1331 }
1332
1333 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1334 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1335
1336 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1337 }
1338
1339 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1340 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1341
1342 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1343 }
1344
1345 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1346 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1347
1348 return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId })
1349 }
1350
1351 static loadForGetAPI (parameters: {
1352 id: number | string
1353 transaction?: Transaction
1354 userId?: number
1355 }): Promise<MVideoDetails> {
1356 const { id, transaction, userId } = parameters
1357 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1358
1359 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1360 }
1361
1362 static async getStats () {
1363 const totalLocalVideos = await VideoModel.count({
1364 where: {
1365 remote: false
1366 }
1367 })
1368
1369 let totalLocalVideoViews = await VideoModel.sum('views', {
1370 where: {
1371 remote: false
1372 }
1373 })
1374
1375 // Sequelize could return null...
1376 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1377
1378 const serverActor = await getServerActor()
1379
1380 const { total: totalVideos } = await VideoModel.listForApi({
1381 start: 0,
1382 count: 0,
1383 sort: '-publishedAt',
1384 nsfw: buildNSFWFilter(),
1385 displayOnlyForFollower: {
1386 actorId: serverActor.id,
1387 orLocalVideos: true
1388 }
1389 })
1390
1391 return {
1392 totalLocalVideos,
1393 totalLocalVideoViews,
1394 totalVideos
1395 }
1396 }
1397
1398 static incrementViews (id: number, views: number) {
1399 return VideoModel.increment('views', {
1400 by: views,
1401 where: {
1402 id
1403 }
1404 })
1405 }
1406
1407 static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
1408 const field = type === 'like'
1409 ? 'likes'
1410 : 'dislikes'
1411
1412 const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
1413
1414 return AccountVideoRateModel.sequelize.query(rawQuery, {
1415 transaction: t,
1416 replacements: { videoId, rateType: type, count },
1417 type: QueryTypes.UPDATE
1418 })
1419 }
1420
1421 static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
1422 const field = type === 'like'
1423 ? 'likes'
1424 : 'dislikes'
1425
1426 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1427 '(' +
1428 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1429 ') ' +
1430 'WHERE "video"."id" = :videoId'
1431
1432 return AccountVideoRateModel.sequelize.query(rawQuery, {
1433 transaction: t,
1434 replacements: { videoId, rateType: type },
1435 type: QueryTypes.UPDATE
1436 })
1437 }
1438
1439 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1440 // Instances only share videos
1441 const query = 'SELECT 1 FROM "videoShare" ' +
1442 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1443 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1444 'LIMIT 1'
1445
1446 const options = {
1447 type: QueryTypes.SELECT as QueryTypes.SELECT,
1448 bind: { followerActorId, videoId },
1449 raw: true
1450 }
1451
1452 return VideoModel.sequelize.query(query, options)
1453 .then(results => results.length === 1)
1454 }
1455
1456 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1457 const options = {
1458 where: {
1459 channelId: ofChannel.id
1460 },
1461 transaction: t
1462 }
1463
1464 return VideoModel.update({ support: ofChannel.support }, options)
1465 }
1466
1467 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1468 const query = {
1469 attributes: [ 'id' ],
1470 where: {
1471 channelId: videoChannel.id
1472 }
1473 }
1474
1475 return VideoModel.findAll(query)
1476 .then(videos => videos.map(v => v.id))
1477 }
1478
1479 // threshold corresponds to how many video the field should have to be returned
1480 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1481 const serverActor = await getServerActor()
1482
1483 const queryOptions: BuildVideosListQueryOptions = {
1484 attributes: [ `"${field}"` ],
1485 group: `GROUP BY "${field}"`,
1486 having: `HAVING COUNT("${field}") >= ${threshold}`,
1487 start: 0,
1488 sort: 'random',
1489 count,
1490 serverAccountIdForBlock: serverActor.Account.id,
1491 displayOnlyForFollower: {
1492 actorId: serverActor.id,
1493 orLocalVideos: true
1494 }
1495 }
1496
1497 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1498
1499 return queryBuilder.queryVideoIds(queryOptions)
1500 .then(rows => rows.map(r => r[field]))
1501 }
1502
1503 static buildTrendingQuery (trendingDays: number) {
1504 return {
1505 attributes: [],
1506 subQuery: false,
1507 model: VideoViewModel,
1508 required: false,
1509 where: {
1510 startDate: {
1511 // FIXME: ts error
1512 [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1513 }
1514 }
1515 }
1516 }
1517
1518 private static async getAvailableForApi (
1519 options: BuildVideosListQueryOptions,
1520 countVideos = true
1521 ): Promise<ResultList<VideoModel>> {
1522 function getCount () {
1523 if (countVideos !== true) return Promise.resolve(undefined)
1524
1525 const countOptions = Object.assign({}, options, { isCount: true })
1526 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1527
1528 return queryBuilder.countVideoIds(countOptions)
1529 }
1530
1531 function getModels () {
1532 if (options.count === 0) return Promise.resolve([])
1533
1534 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1535
1536 return queryBuilder.queryVideos(options)
1537 }
1538
1539 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1540
1541 return {
1542 data: rows,
1543 total: count
1544 }
1545 }
1546
1547 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1548 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1549 throw new Error('Try to filter all-local but user cannot see all videos')
1550 }
1551 }
1552
1553 private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
1554 if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1555 throw new Error('Try to choose video privacies but user cannot see all videos')
1556 }
1557 }
1558
1559 private static isPrivateInclude (include: VideoInclude) {
1560 return include & VideoInclude.BLACKLISTED ||
1561 include & VideoInclude.BLOCKED_OWNER ||
1562 include & VideoInclude.NOT_PUBLISHED_STATE
1563 }
1564
1565 isBlacklisted () {
1566 return !!this.VideoBlacklist
1567 }
1568
1569 isBlocked () {
1570 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1571 }
1572
1573 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1574 // We first transcode to WebTorrent format, so try this array first
1575 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1576 const file = fun(this.VideoFiles, file => file.resolution)
1577
1578 return Object.assign(file, { Video: this })
1579 }
1580
1581 // No webtorrent files, try with streaming playlist files
1582 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1583 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1584
1585 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1586 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1587 }
1588
1589 return undefined
1590 }
1591
1592 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1593 return this.getQualityFileBy(maxBy)
1594 }
1595
1596 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1597 return this.getQualityFileBy(minBy)
1598 }
1599
1600 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1601 if (Array.isArray(this.VideoFiles) === false) return undefined
1602
1603 const file = this.VideoFiles.find(f => f.resolution === resolution)
1604 if (!file) return undefined
1605
1606 return Object.assign(file, { Video: this })
1607 }
1608
1609 hasWebTorrentFiles () {
1610 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1611 }
1612
1613 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1614 thumbnail.videoId = this.id
1615
1616 const savedThumbnail = await thumbnail.save({ transaction })
1617
1618 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1619
1620 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1621 this.Thumbnails.push(savedThumbnail)
1622 }
1623
1624 getMiniature () {
1625 if (Array.isArray(this.Thumbnails) === false) return undefined
1626
1627 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1628 }
1629
1630 hasPreview () {
1631 return !!this.getPreview()
1632 }
1633
1634 getPreview () {
1635 if (Array.isArray(this.Thumbnails) === false) return undefined
1636
1637 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1638 }
1639
1640 isOwned () {
1641 return this.remote === false
1642 }
1643
1644 getWatchStaticPath () {
1645 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1646 }
1647
1648 getEmbedStaticPath () {
1649 return buildVideoEmbedPath(this)
1650 }
1651
1652 getMiniatureStaticPath () {
1653 const thumbnail = this.getMiniature()
1654 if (!thumbnail) return null
1655
1656 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1657 }
1658
1659 getPreviewStaticPath () {
1660 const preview = this.getPreview()
1661 if (!preview) return null
1662
1663 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1664 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1665 }
1666
1667 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1668 return videoModelToFormattedJSON(this, options)
1669 }
1670
1671 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1672 return videoModelToFormattedDetailsJSON(this)
1673 }
1674
1675 getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
1676 let files: VideoFile[] = []
1677
1678 if (Array.isArray(this.VideoFiles)) {
1679 const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet)
1680 files = files.concat(result)
1681 }
1682
1683 for (const p of (this.VideoStreamingPlaylists || [])) {
1684 const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet)
1685 files = files.concat(result)
1686 }
1687
1688 return files
1689 }
1690
1691 toActivityPubObject (this: MVideoAP): VideoObject {
1692 return videoModelToActivityPubObject(this)
1693 }
1694
1695 getTruncatedDescription () {
1696 if (!this.description) return null
1697
1698 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1699 return peertubeTruncate(this.description, { length: maxLength })
1700 }
1701
1702 getAllFiles () {
1703 let files: MVideoFile[] = []
1704
1705 if (Array.isArray(this.VideoFiles)) {
1706 files = files.concat(this.VideoFiles)
1707 }
1708
1709 if (Array.isArray(this.VideoStreamingPlaylists)) {
1710 for (const p of this.VideoStreamingPlaylists) {
1711 if (Array.isArray(p.VideoFiles)) {
1712 files = files.concat(p.VideoFiles)
1713 }
1714 }
1715 }
1716
1717 return files
1718 }
1719
1720 probeMaxQualityFile () {
1721 const file = this.getMaxQualityFile()
1722 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1723
1724 return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
1725 const probe = await ffprobePromise(originalFilePath)
1726
1727 const { audioStream } = await getAudioStream(originalFilePath, probe)
1728
1729 return {
1730 audioStream,
1731
1732 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1733 }
1734 })
1735 }
1736
1737 getDescriptionAPIPath () {
1738 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1739 }
1740
1741 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1742 if (!this.VideoStreamingPlaylists) return undefined
1743
1744 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1745 if (!playlist) return undefined
1746
1747 playlist.Video = this
1748
1749 return playlist
1750 }
1751
1752 setHLSPlaylist (playlist: MStreamingPlaylist) {
1753 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1754
1755 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1756 this.VideoStreamingPlaylists = toAdd
1757 return
1758 }
1759
1760 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1761 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1762 .concat(toAdd)
1763 }
1764
1765 removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
1766 const filePath = isRedundancy
1767 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1768 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1769
1770 const promises: Promise<any>[] = [ remove(filePath) ]
1771 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1772
1773 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1774 promises.push(removeWebTorrentObjectStorage(videoFile))
1775 }
1776
1777 return Promise.all(promises)
1778 }
1779
1780 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1781 const directoryPath = isRedundancy
1782 ? getHLSRedundancyDirectory(this)
1783 : getHLSDirectory(this)
1784
1785 await remove(directoryPath)
1786
1787 if (isRedundancy !== true) {
1788 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1789 streamingPlaylistWithFiles.Video = this
1790
1791 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1792 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1793 }
1794
1795 // Remove physical files and torrents
1796 await Promise.all(
1797 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1798 )
1799
1800 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1801 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1802 }
1803 }
1804 }
1805
1806 isOutdated () {
1807 if (this.isOwned()) return false
1808
1809 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1810 }
1811
1812 hasPrivacyForFederation () {
1813 return isPrivacyForFederation(this.privacy)
1814 }
1815
1816 hasStateForFederation () {
1817 return isStateForFederation(this.state)
1818 }
1819
1820 isNewVideo (newPrivacy: VideoPrivacy) {
1821 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1822 }
1823
1824 setAsRefreshed (transaction?: Transaction) {
1825 return setAsUpdated('video', this.id, transaction)
1826 }
1827
1828 requiresAuth () {
1829 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
1830 }
1831
1832 setPrivacy (newPrivacy: VideoPrivacy) {
1833 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
1834 this.publishedAt = new Date()
1835 }
1836
1837 this.privacy = newPrivacy
1838 }
1839
1840 isConfidential () {
1841 return this.privacy === VideoPrivacy.PRIVATE ||
1842 this.privacy === VideoPrivacy.UNLISTED ||
1843 this.privacy === VideoPrivacy.INTERNAL
1844 }
1845
1846 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
1847 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
1848
1849 this.state = newState
1850
1851 if (this.state === VideoState.PUBLISHED && isNewVideo) {
1852 this.publishedAt = new Date()
1853 }
1854
1855 await this.save({ transaction })
1856 }
1857
1858 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1859 return Math.ceil((videoFile.size * 8) / this.duration)
1860 }
1861
1862 getTrackerUrls () {
1863 if (this.isOwned()) {
1864 return [
1865 WEBSERVER.URL + '/tracker/announce',
1866 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
1867 ]
1868 }
1869
1870 return this.Trackers.map(t => t.url)
1871 }
1872 }