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