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