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