]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
4044287eea39e569cb5deb549e333b4d596eed3a
[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 { VideoFile } from '@shared/models/videos/video-file.model'
38 import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
39 import { VideoObject } from '../../../shared/models/activitypub/objects'
40 import { Video, VideoDetails, VideoRateType, VideoStorage } from '../../../shared/models/videos'
41 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
42 import { VideoFilter } from '../../../shared/models/videos/video-query.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 { 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 { VideosModelGetQueryBuilder } from './sql/video-model-get-query-builder'
109 import { BuildVideosListQueryOptions, 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 export type AvailableForListIDsOptions = {
149 serverAccountId: number
150 followerActorId: number
151 includeLocalVideos: boolean
152
153 attributesType?: 'none' | 'id' | 'all'
154
155 filter?: VideoFilter
156 categoryOneOf?: number[]
157 nsfw?: boolean
158 licenceOneOf?: number[]
159 languageOneOf?: string[]
160 tagsOneOf?: string[]
161 tagsAllOf?: string[]
162
163 withFiles?: boolean
164
165 accountId?: number
166 videoChannelId?: number
167
168 videoPlaylistId?: number
169
170 trendingDays?: number
171 user?: MUserAccountId
172 historyOfUser?: MUserId
173
174 baseWhere?: WhereOptions[]
175 }
176
177 @Scopes(() => ({
178 [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: {
179 attributes: [ 'id', 'url', 'uuid', 'remote' ]
180 },
181 [ScopeNames.FOR_API]: (options: ForAPIOptions) => {
182 const include: Includeable[] = [
183 {
184 model: VideoChannelModel.scope({
185 method: [
186 VideoChannelScopeNames.SUMMARY, {
187 withAccount: true,
188 withAccountBlockerIds: options.withAccountBlockerIds
189 } as SummaryOptions
190 ]
191 }),
192 required: true
193 },
194 {
195 attributes: [ 'type', 'filename' ],
196 model: ThumbnailModel,
197 required: false
198 }
199 ]
200
201 const query: FindOptions = {}
202
203 if (options.ids) {
204 query.where = {
205 id: {
206 [Op.in]: options.ids
207 }
208 }
209 }
210
211 if (options.videoPlaylistId) {
212 include.push({
213 model: VideoPlaylistElementModel.unscoped(),
214 required: true,
215 where: {
216 videoPlaylistId: options.videoPlaylistId
217 }
218 })
219 }
220
221 query.include = include
222
223 return query
224 },
225 [ScopeNames.WITH_THUMBNAILS]: {
226 include: [
227 {
228 model: ThumbnailModel,
229 required: false
230 }
231 ]
232 },
233 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
234 include: [
235 {
236 model: VideoChannelModel.unscoped(),
237 required: true,
238 include: [
239 {
240 attributes: {
241 exclude: [ 'privateKey', 'publicKey' ]
242 },
243 model: ActorModel.unscoped(),
244 required: true,
245 include: [
246 {
247 attributes: [ 'host' ],
248 model: ServerModel.unscoped(),
249 required: false
250 },
251 {
252 model: ActorImageModel.unscoped(),
253 as: 'Avatar',
254 required: false
255 }
256 ]
257 },
258 {
259 model: AccountModel.unscoped(),
260 required: true,
261 include: [
262 {
263 model: ActorModel.unscoped(),
264 attributes: {
265 exclude: [ 'privateKey', 'publicKey' ]
266 },
267 required: true,
268 include: [
269 {
270 attributes: [ 'host' ],
271 model: ServerModel.unscoped(),
272 required: false
273 },
274 {
275 model: ActorImageModel.unscoped(),
276 as: 'Avatar',
277 required: false
278 }
279 ]
280 }
281 ]
282 }
283 ]
284 }
285 ]
286 },
287 [ScopeNames.WITH_TAGS]: {
288 include: [ TagModel ]
289 },
290 [ScopeNames.WITH_BLACKLISTED]: {
291 include: [
292 {
293 attributes: [ 'id', 'reason', 'unfederated' ],
294 model: VideoBlacklistModel,
295 required: false
296 }
297 ]
298 },
299 [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => {
300 let subInclude: any[] = []
301
302 if (withRedundancies === true) {
303 subInclude = [
304 {
305 attributes: [ 'fileUrl' ],
306 model: VideoRedundancyModel.unscoped(),
307 required: false
308 }
309 ]
310 }
311
312 return {
313 include: [
314 {
315 model: VideoFileModel,
316 separate: true,
317 required: false,
318 include: subInclude
319 }
320 ]
321 }
322 },
323 [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => {
324 const subInclude: IncludeOptions[] = [
325 {
326 model: VideoFileModel,
327 required: false
328 }
329 ]
330
331 if (withRedundancies === true) {
332 subInclude.push({
333 attributes: [ 'fileUrl' ],
334 model: VideoRedundancyModel.unscoped(),
335 required: false
336 })
337 }
338
339 return {
340 include: [
341 {
342 model: VideoStreamingPlaylistModel.unscoped(),
343 required: false,
344 separate: true,
345 include: subInclude
346 }
347 ]
348 }
349 },
350 [ScopeNames.WITH_SCHEDULED_UPDATE]: {
351 include: [
352 {
353 model: ScheduleVideoUpdateModel.unscoped(),
354 required: false
355 }
356 ]
357 },
358 [ScopeNames.WITH_USER_HISTORY]: (userId: number) => {
359 return {
360 include: [
361 {
362 attributes: [ 'currentTime' ],
363 model: UserVideoHistoryModel.unscoped(),
364 required: false,
365 where: {
366 userId
367 }
368 }
369 ]
370 }
371 }
372 }))
373 @Table({
374 tableName: 'video',
375 indexes: [
376 buildTrigramSearchIndex('video_name_trigram', 'name'),
377
378 { fields: [ 'createdAt' ] },
379 {
380 fields: [
381 { name: 'publishedAt', order: 'DESC' },
382 { name: 'id', order: 'ASC' }
383 ]
384 },
385 { fields: [ 'duration' ] },
386 {
387 fields: [
388 { name: 'views', order: 'DESC' },
389 { name: 'id', order: 'ASC' }
390 ]
391 },
392 { fields: [ 'channelId' ] },
393 {
394 fields: [ 'originallyPublishedAt' ],
395 where: {
396 originallyPublishedAt: {
397 [Op.ne]: null
398 }
399 }
400 },
401 {
402 fields: [ 'category' ], // We don't care videos with an unknown category
403 where: {
404 category: {
405 [Op.ne]: null
406 }
407 }
408 },
409 {
410 fields: [ 'licence' ], // We don't care videos with an unknown licence
411 where: {
412 licence: {
413 [Op.ne]: null
414 }
415 }
416 },
417 {
418 fields: [ 'language' ], // We don't care videos with an unknown language
419 where: {
420 language: {
421 [Op.ne]: null
422 }
423 }
424 },
425 {
426 fields: [ 'nsfw' ], // Most of the videos are not NSFW
427 where: {
428 nsfw: true
429 }
430 },
431 {
432 fields: [ 'remote' ], // Only index local videos
433 where: {
434 remote: false
435 }
436 },
437 {
438 fields: [ 'uuid' ],
439 unique: true
440 },
441 {
442 fields: [ 'url' ],
443 unique: true
444 }
445 ]
446 })
447 export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
448
449 @AllowNull(false)
450 @Default(DataType.UUIDV4)
451 @IsUUID(4)
452 @Column(DataType.UUID)
453 uuid: string
454
455 @AllowNull(false)
456 @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
457 @Column
458 name: string
459
460 @AllowNull(true)
461 @Default(null)
462 @Column
463 category: number
464
465 @AllowNull(true)
466 @Default(null)
467 @Column
468 licence: number
469
470 @AllowNull(true)
471 @Default(null)
472 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
473 language: string
474
475 @AllowNull(false)
476 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
477 @Column
478 privacy: VideoPrivacy
479
480 @AllowNull(false)
481 @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
482 @Column
483 nsfw: boolean
484
485 @AllowNull(true)
486 @Default(null)
487 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
488 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
489 description: string
490
491 @AllowNull(true)
492 @Default(null)
493 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
494 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
495 support: string
496
497 @AllowNull(false)
498 @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
499 @Column
500 duration: number
501
502 @AllowNull(false)
503 @Default(0)
504 @IsInt
505 @Min(0)
506 @Column
507 views: number
508
509 @AllowNull(false)
510 @Default(0)
511 @IsInt
512 @Min(0)
513 @Column
514 likes: number
515
516 @AllowNull(false)
517 @Default(0)
518 @IsInt
519 @Min(0)
520 @Column
521 dislikes: number
522
523 @AllowNull(false)
524 @Column
525 remote: boolean
526
527 @AllowNull(false)
528 @Default(false)
529 @Column
530 isLive: boolean
531
532 @AllowNull(false)
533 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
534 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
535 url: string
536
537 @AllowNull(false)
538 @Column
539 commentsEnabled: boolean
540
541 @AllowNull(false)
542 @Column
543 downloadEnabled: boolean
544
545 @AllowNull(false)
546 @Column
547 waitTranscoding: boolean
548
549 @AllowNull(false)
550 @Default(null)
551 @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
552 @Column
553 state: VideoState
554
555 @CreatedAt
556 createdAt: Date
557
558 @UpdatedAt
559 updatedAt: Date
560
561 @AllowNull(false)
562 @Default(DataType.NOW)
563 @Column
564 publishedAt: Date
565
566 @AllowNull(true)
567 @Default(null)
568 @Column
569 originallyPublishedAt: Date
570
571 @ForeignKey(() => VideoChannelModel)
572 @Column
573 channelId: number
574
575 @BelongsTo(() => VideoChannelModel, {
576 foreignKey: {
577 allowNull: true
578 },
579 onDelete: 'cascade'
580 })
581 VideoChannel: VideoChannelModel
582
583 @BelongsToMany(() => TagModel, {
584 foreignKey: 'videoId',
585 through: () => VideoTagModel,
586 onDelete: 'CASCADE'
587 })
588 Tags: TagModel[]
589
590 @BelongsToMany(() => TrackerModel, {
591 foreignKey: 'videoId',
592 through: () => VideoTrackerModel,
593 onDelete: 'CASCADE'
594 })
595 Trackers: TrackerModel[]
596
597 @HasMany(() => ThumbnailModel, {
598 foreignKey: {
599 name: 'videoId',
600 allowNull: true
601 },
602 hooks: true,
603 onDelete: 'cascade'
604 })
605 Thumbnails: ThumbnailModel[]
606
607 @HasMany(() => VideoPlaylistElementModel, {
608 foreignKey: {
609 name: 'videoId',
610 allowNull: true
611 },
612 onDelete: 'set null'
613 })
614 VideoPlaylistElements: VideoPlaylistElementModel[]
615
616 @HasMany(() => VideoAbuseModel, {
617 foreignKey: {
618 name: 'videoId',
619 allowNull: true
620 },
621 onDelete: 'set null'
622 })
623 VideoAbuses: VideoAbuseModel[]
624
625 @HasMany(() => VideoFileModel, {
626 foreignKey: {
627 name: 'videoId',
628 allowNull: true
629 },
630 hooks: true,
631 onDelete: 'cascade'
632 })
633 VideoFiles: VideoFileModel[]
634
635 @HasMany(() => VideoStreamingPlaylistModel, {
636 foreignKey: {
637 name: 'videoId',
638 allowNull: false
639 },
640 hooks: true,
641 onDelete: 'cascade'
642 })
643 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
644
645 @HasMany(() => VideoShareModel, {
646 foreignKey: {
647 name: 'videoId',
648 allowNull: false
649 },
650 onDelete: 'cascade'
651 })
652 VideoShares: VideoShareModel[]
653
654 @HasMany(() => AccountVideoRateModel, {
655 foreignKey: {
656 name: 'videoId',
657 allowNull: false
658 },
659 onDelete: 'cascade'
660 })
661 AccountVideoRates: AccountVideoRateModel[]
662
663 @HasMany(() => VideoCommentModel, {
664 foreignKey: {
665 name: 'videoId',
666 allowNull: false
667 },
668 onDelete: 'cascade',
669 hooks: true
670 })
671 VideoComments: VideoCommentModel[]
672
673 @HasMany(() => VideoViewModel, {
674 foreignKey: {
675 name: 'videoId',
676 allowNull: false
677 },
678 onDelete: 'cascade'
679 })
680 VideoViews: VideoViewModel[]
681
682 @HasMany(() => UserVideoHistoryModel, {
683 foreignKey: {
684 name: 'videoId',
685 allowNull: false
686 },
687 onDelete: 'cascade'
688 })
689 UserVideoHistories: UserVideoHistoryModel[]
690
691 @HasOne(() => ScheduleVideoUpdateModel, {
692 foreignKey: {
693 name: 'videoId',
694 allowNull: false
695 },
696 onDelete: 'cascade'
697 })
698 ScheduleVideoUpdate: ScheduleVideoUpdateModel
699
700 @HasOne(() => VideoBlacklistModel, {
701 foreignKey: {
702 name: 'videoId',
703 allowNull: false
704 },
705 onDelete: 'cascade'
706 })
707 VideoBlacklist: VideoBlacklistModel
708
709 @HasOne(() => VideoLiveModel, {
710 foreignKey: {
711 name: 'videoId',
712 allowNull: false
713 },
714 onDelete: 'cascade'
715 })
716 VideoLive: VideoLiveModel
717
718 @HasOne(() => VideoImportModel, {
719 foreignKey: {
720 name: 'videoId',
721 allowNull: true
722 },
723 onDelete: 'set null'
724 })
725 VideoImport: VideoImportModel
726
727 @HasMany(() => VideoCaptionModel, {
728 foreignKey: {
729 name: 'videoId',
730 allowNull: false
731 },
732 onDelete: 'cascade',
733 hooks: true,
734 ['separate' as any]: true
735 })
736 VideoCaptions: VideoCaptionModel[]
737
738 @HasOne(() => VideoJobInfoModel, {
739 foreignKey: {
740 name: 'videoId',
741 allowNull: false
742 },
743 onDelete: 'cascade'
744 })
745 VideoJobInfo: VideoJobInfoModel
746
747 @BeforeDestroy
748 static async sendDelete (instance: MVideoAccountLight, options) {
749 if (!instance.isOwned()) return undefined
750
751 // Lazy load channels
752 if (!instance.VideoChannel) {
753 instance.VideoChannel = await instance.$get('VideoChannel', {
754 include: [
755 ActorModel,
756 AccountModel
757 ],
758 transaction: options.transaction
759 }) as MChannelAccountDefault
760 }
761
762 return sendDeleteVideo(instance, options.transaction)
763 }
764
765 @BeforeDestroy
766 static async removeFiles (instance: VideoModel, options) {
767 const tasks: Promise<any>[] = []
768
769 logger.info('Removing files of video %s.', instance.url)
770
771 if (instance.isOwned()) {
772 if (!Array.isArray(instance.VideoFiles)) {
773 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
774 }
775
776 // Remove physical files and torrents
777 instance.VideoFiles.forEach(file => {
778 tasks.push(instance.removeFileAndTorrent(file))
779 })
780
781 // Remove playlists file
782 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
783 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
784 }
785
786 for (const p of instance.VideoStreamingPlaylists) {
787 tasks.push(instance.removeStreamingPlaylistFiles(p))
788 }
789 }
790
791 // Do not wait video deletion because we could be in a transaction
792 Promise.all(tasks)
793 .catch(err => {
794 logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
795 })
796
797 return undefined
798 }
799
800 @BeforeDestroy
801 static stopLiveIfNeeded (instance: VideoModel) {
802 if (!instance.isLive) return
803
804 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
805
806 LiveManager.Instance.stopSessionOf(instance.id)
807 }
808
809 @BeforeDestroy
810 static invalidateCache (instance: VideoModel) {
811 ModelCache.Instance.invalidateCache('video', instance.id)
812 }
813
814 @BeforeDestroy
815 static async saveEssentialDataToAbuses (instance: VideoModel, options) {
816 const tasks: Promise<any>[] = []
817
818 if (!Array.isArray(instance.VideoAbuses)) {
819 instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
820
821 if (instance.VideoAbuses.length === 0) return undefined
822 }
823
824 logger.info('Saving video abuses details of video %s.', instance.url)
825
826 if (!instance.Trackers) instance.Trackers = await instance.$get('Trackers', { transaction: options.transaction })
827 const details = instance.toFormattedDetailsJSON()
828
829 for (const abuse of instance.VideoAbuses) {
830 abuse.deletedVideo = details
831 tasks.push(abuse.save({ transaction: options.transaction }))
832 }
833
834 await Promise.all(tasks)
835 }
836
837 static listLocal (): Promise<MVideo[]> {
838 const query = {
839 where: {
840 remote: false
841 }
842 }
843
844 return VideoModel.findAll(query)
845 }
846
847 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
848 function getRawQuery (select: string) {
849 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
850 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
851 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
852 'WHERE "Account"."actorId" = ' + actorId
853 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
854 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
855 'WHERE "VideoShare"."actorId" = ' + actorId
856
857 return `(${queryVideo}) UNION (${queryVideoShare})`
858 }
859
860 const rawQuery = getRawQuery('"Video"."id"')
861 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
862
863 const query = {
864 distinct: true,
865 offset: start,
866 limit: count,
867 order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
868 where: {
869 id: {
870 [Op.in]: Sequelize.literal('(' + rawQuery + ')')
871 },
872 [Op.or]: getPrivaciesForFederation()
873 },
874 include: [
875 {
876 attributes: [ 'filename', 'language', 'fileUrl' ],
877 model: VideoCaptionModel.unscoped(),
878 required: false
879 },
880 {
881 attributes: [ 'id', 'url' ],
882 model: VideoShareModel.unscoped(),
883 required: false,
884 // We only want videos shared by this actor
885 where: {
886 [Op.and]: [
887 {
888 id: {
889 [Op.not]: null
890 }
891 },
892 {
893 actorId
894 }
895 ]
896 },
897 include: [
898 {
899 attributes: [ 'id', 'url' ],
900 model: ActorModel.unscoped()
901 }
902 ]
903 },
904 {
905 model: VideoChannelModel.unscoped(),
906 required: true,
907 include: [
908 {
909 attributes: [ 'name' ],
910 model: AccountModel.unscoped(),
911 required: true,
912 include: [
913 {
914 attributes: [ 'id', 'url', 'followersUrl' ],
915 model: ActorModel.unscoped(),
916 required: true
917 }
918 ]
919 },
920 {
921 attributes: [ 'id', 'url', 'followersUrl' ],
922 model: ActorModel.unscoped(),
923 required: true
924 }
925 ]
926 },
927 {
928 model: VideoStreamingPlaylistModel.unscoped(),
929 required: false,
930 include: [
931 {
932 model: VideoFileModel,
933 required: false
934 }
935 ]
936 },
937 VideoLiveModel.unscoped(),
938 VideoFileModel,
939 TagModel
940 ]
941 }
942
943 return Bluebird.all([
944 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
945 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
946 ]).then(([ rows, totals ]) => {
947 // totals: totalVideos + totalVideoShares
948 let totalVideos = 0
949 let totalVideoShares = 0
950 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
951 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
952
953 const total = totalVideos + totalVideoShares
954 return {
955 data: rows,
956 total: total
957 }
958 })
959 }
960
961 static async listPublishedLiveUUIDs () {
962 const options = {
963 attributes: [ 'uuid' ],
964 where: {
965 isLive: true,
966 remote: false,
967 state: VideoState.PUBLISHED
968 }
969 }
970
971 const result = await VideoModel.findAll(options)
972
973 return result.map(v => v.uuid)
974 }
975
976 static listUserVideosForApi (options: {
977 accountId: number
978 start: number
979 count: number
980 sort: string
981
982 channelId?: number
983 isLive?: boolean
984 search?: string
985 }) {
986 const { accountId, channelId, start, count, sort, search, isLive } = options
987
988 function buildBaseQuery (): FindOptions {
989 const where: WhereOptions = {}
990
991 if (search) {
992 where.name = {
993 [Op.iLike]: '%' + search + '%'
994 }
995 }
996
997 if (isLive) {
998 where.isLive = isLive
999 }
1000
1001 const channelWhere = channelId
1002 ? { id: channelId }
1003 : {}
1004
1005 const baseQuery = {
1006 offset: start,
1007 limit: count,
1008 where,
1009 order: getVideoSort(sort),
1010 include: [
1011 {
1012 model: VideoChannelModel,
1013 required: true,
1014 where: channelWhere,
1015 include: [
1016 {
1017 model: AccountModel,
1018 where: {
1019 id: accountId
1020 },
1021 required: true
1022 }
1023 ]
1024 }
1025 ]
1026 }
1027
1028 return baseQuery
1029 }
1030
1031 const countQuery = buildBaseQuery()
1032 const findQuery = buildBaseQuery()
1033
1034 const findScopes: (string | ScopeOptions)[] = [
1035 ScopeNames.WITH_SCHEDULED_UPDATE,
1036 ScopeNames.WITH_BLACKLISTED,
1037 ScopeNames.WITH_THUMBNAILS
1038 ]
1039
1040 return Promise.all([
1041 VideoModel.count(countQuery),
1042 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1043 ]).then(([ count, rows ]) => {
1044 return {
1045 data: rows,
1046 total: count
1047 }
1048 })
1049 }
1050
1051 static async listForApi (options: {
1052 start: number
1053 count: number
1054 sort: string
1055
1056 nsfw: boolean
1057 filter?: VideoFilter
1058 isLive?: boolean
1059
1060 includeLocalVideos: boolean
1061 withFiles: boolean
1062
1063 categoryOneOf?: number[]
1064 licenceOneOf?: number[]
1065 languageOneOf?: string[]
1066 tagsOneOf?: string[]
1067 tagsAllOf?: string[]
1068
1069 accountId?: number
1070 videoChannelId?: number
1071
1072 followerActorId?: number
1073
1074 videoPlaylistId?: number
1075
1076 trendingDays?: number
1077
1078 user?: MUserAccountId
1079 historyOfUser?: MUserId
1080
1081 countVideos?: boolean
1082
1083 search?: string
1084 }) {
1085 if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1086 throw new Error('Try to filter all-local but no user has not the see all videos right')
1087 }
1088
1089 const trendingDays = options.sort.endsWith('trending')
1090 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1091 : undefined
1092
1093 let trendingAlgorithm: string
1094 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1095 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1096
1097 const serverActor = await getServerActor()
1098
1099 // followerActorId === null has a meaning, so just check undefined
1100 const followerActorId = options.followerActorId !== undefined
1101 ? options.followerActorId
1102 : serverActor.id
1103
1104 const queryOptions = {
1105 ...pick(options, [
1106 'start',
1107 'count',
1108 'sort',
1109 'nsfw',
1110 'isLive',
1111 'categoryOneOf',
1112 'licenceOneOf',
1113 'languageOneOf',
1114 'tagsOneOf',
1115 'tagsAllOf',
1116 'filter',
1117 'withFiles',
1118 'accountId',
1119 'videoChannelId',
1120 'videoPlaylistId',
1121 'includeLocalVideos',
1122 'user',
1123 'historyOfUser',
1124 'search'
1125 ]),
1126
1127 followerActorId,
1128 serverAccountId: serverActor.Account.id,
1129 trendingDays,
1130 trendingAlgorithm
1131 }
1132
1133 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1134 }
1135
1136 static async searchAndPopulateAccountAndServer (options: {
1137 start: number
1138 count: number
1139 sort: string
1140 includeLocalVideos: boolean
1141 search?: string
1142 host?: string
1143 startDate?: string // ISO 8601
1144 endDate?: string // ISO 8601
1145 originallyPublishedStartDate?: string
1146 originallyPublishedEndDate?: string
1147 nsfw?: boolean
1148 isLive?: boolean
1149 categoryOneOf?: number[]
1150 licenceOneOf?: number[]
1151 languageOneOf?: string[]
1152 tagsOneOf?: string[]
1153 tagsAllOf?: string[]
1154 durationMin?: number // seconds
1155 durationMax?: number // seconds
1156 user?: MUserAccountId
1157 filter?: VideoFilter
1158 uuids?: string[]
1159 }) {
1160 const serverActor = await getServerActor()
1161
1162 const queryOptions = {
1163 ...pick(options, [
1164 'includeLocalVideos',
1165 'nsfw',
1166 'isLive',
1167 'categoryOneOf',
1168 'licenceOneOf',
1169 'languageOneOf',
1170 'tagsOneOf',
1171 'tagsAllOf',
1172 'user',
1173 'filter',
1174 'host',
1175 'start',
1176 'count',
1177 'sort',
1178 'startDate',
1179 'endDate',
1180 'originallyPublishedStartDate',
1181 'originallyPublishedEndDate',
1182 'durationMin',
1183 'durationMax',
1184 'uuids',
1185 'search'
1186 ]),
1187
1188 followerActorId: serverActor.id,
1189 serverAccountId: serverActor.Account.id
1190 }
1191
1192 return VideoModel.getAvailableForApi(queryOptions)
1193 }
1194
1195 static countLocalLives () {
1196 const options = {
1197 where: {
1198 remote: false,
1199 isLive: true,
1200 state: {
1201 [Op.ne]: VideoState.LIVE_ENDED
1202 }
1203 }
1204 }
1205
1206 return VideoModel.count(options)
1207 }
1208
1209 static countVideosUploadedByUserSince (userId: number, since: Date) {
1210 const options = {
1211 include: [
1212 {
1213 model: VideoChannelModel.unscoped(),
1214 required: true,
1215 include: [
1216 {
1217 model: AccountModel.unscoped(),
1218 required: true,
1219 include: [
1220 {
1221 model: UserModel.unscoped(),
1222 required: true,
1223 where: {
1224 id: userId
1225 }
1226 }
1227 ]
1228 }
1229 ]
1230 }
1231 ],
1232 where: {
1233 createdAt: {
1234 [Op.gte]: since
1235 }
1236 }
1237 }
1238
1239 return VideoModel.unscoped().count(options)
1240 }
1241
1242 static countLivesOfAccount (accountId: number) {
1243 const options = {
1244 where: {
1245 remote: false,
1246 isLive: true,
1247 state: {
1248 [Op.ne]: VideoState.LIVE_ENDED
1249 }
1250 },
1251 include: [
1252 {
1253 required: true,
1254 model: VideoChannelModel.unscoped(),
1255 where: {
1256 accountId
1257 }
1258 }
1259 ]
1260 }
1261
1262 return VideoModel.count(options)
1263 }
1264
1265 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1266 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1267
1268 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1269 }
1270
1271 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1272 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1273
1274 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1275 }
1276
1277 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1278 const fun = () => {
1279 const query = {
1280 where: buildWhereIdOrUUID(id),
1281 transaction: t
1282 }
1283
1284 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1285 }
1286
1287 return ModelCache.Instance.doCache({
1288 cacheType: 'load-video-immutable-id',
1289 key: '' + id,
1290 deleteKey: 'video',
1291 fun
1292 })
1293 }
1294
1295 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1296 const fun = () => {
1297 const query: FindOptions = {
1298 where: {
1299 url
1300 },
1301 transaction
1302 }
1303
1304 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1305 }
1306
1307 return ModelCache.Instance.doCache({
1308 cacheType: 'load-video-immutable-url',
1309 key: url,
1310 deleteKey: 'video',
1311 fun
1312 })
1313 }
1314
1315 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1316 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1317
1318 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1319 }
1320
1321 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1322 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1323
1324 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1325 }
1326
1327 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1328 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1329
1330 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1331 }
1332
1333 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1334 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1335
1336 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1337 }
1338
1339 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1340 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1341
1342 return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId })
1343 }
1344
1345 static loadForGetAPI (parameters: {
1346 id: number | string
1347 transaction?: Transaction
1348 userId?: number
1349 }): Promise<MVideoDetails> {
1350 const { id, transaction, userId } = parameters
1351 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1352
1353 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1354 }
1355
1356 static async getStats () {
1357 const totalLocalVideos = await VideoModel.count({
1358 where: {
1359 remote: false
1360 }
1361 })
1362
1363 let totalLocalVideoViews = await VideoModel.sum('views', {
1364 where: {
1365 remote: false
1366 }
1367 })
1368
1369 // Sequelize could return null...
1370 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1371
1372 const { total: totalVideos } = await VideoModel.listForApi({
1373 start: 0,
1374 count: 0,
1375 sort: '-publishedAt',
1376 nsfw: buildNSFWFilter(),
1377 includeLocalVideos: true,
1378 withFiles: false
1379 })
1380
1381 return {
1382 totalLocalVideos,
1383 totalLocalVideoViews,
1384 totalVideos
1385 }
1386 }
1387
1388 static incrementViews (id: number, views: number) {
1389 return VideoModel.increment('views', {
1390 by: views,
1391 where: {
1392 id
1393 }
1394 })
1395 }
1396
1397 static updateRatesOf (videoId: number, type: VideoRateType, t: Transaction) {
1398 const field = type === 'like'
1399 ? 'likes'
1400 : 'dislikes'
1401
1402 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1403 '(' +
1404 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1405 ') ' +
1406 'WHERE "video"."id" = :videoId'
1407
1408 return AccountVideoRateModel.sequelize.query(rawQuery, {
1409 transaction: t,
1410 replacements: { videoId, rateType: type },
1411 type: QueryTypes.UPDATE
1412 })
1413 }
1414
1415 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1416 // Instances only share videos
1417 const query = 'SELECT 1 FROM "videoShare" ' +
1418 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1419 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1420 'LIMIT 1'
1421
1422 const options = {
1423 type: QueryTypes.SELECT as QueryTypes.SELECT,
1424 bind: { followerActorId, videoId },
1425 raw: true
1426 }
1427
1428 return VideoModel.sequelize.query(query, options)
1429 .then(results => results.length === 1)
1430 }
1431
1432 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1433 const options = {
1434 where: {
1435 channelId: ofChannel.id
1436 },
1437 transaction: t
1438 }
1439
1440 return VideoModel.update({ support: ofChannel.support }, options)
1441 }
1442
1443 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1444 const query = {
1445 attributes: [ 'id' ],
1446 where: {
1447 channelId: videoChannel.id
1448 }
1449 }
1450
1451 return VideoModel.findAll(query)
1452 .then(videos => videos.map(v => v.id))
1453 }
1454
1455 // threshold corresponds to how many video the field should have to be returned
1456 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1457 const serverActor = await getServerActor()
1458 const followerActorId = serverActor.id
1459
1460 const queryOptions: BuildVideosListQueryOptions = {
1461 attributes: [ `"${field}"` ],
1462 group: `GROUP BY "${field}"`,
1463 having: `HAVING COUNT("${field}") >= ${threshold}`,
1464 start: 0,
1465 sort: 'random',
1466 count,
1467 serverAccountId: serverActor.Account.id,
1468 followerActorId,
1469 includeLocalVideos: true
1470 }
1471
1472 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1473
1474 return queryBuilder.queryVideoIds(queryOptions)
1475 .then(rows => rows.map(r => r[field]))
1476 }
1477
1478 static buildTrendingQuery (trendingDays: number) {
1479 return {
1480 attributes: [],
1481 subQuery: false,
1482 model: VideoViewModel,
1483 required: false,
1484 where: {
1485 startDate: {
1486 [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1487 }
1488 }
1489 }
1490 }
1491
1492 private static async getAvailableForApi (
1493 options: BuildVideosListQueryOptions,
1494 countVideos = true
1495 ): Promise<ResultList<VideoModel>> {
1496 function getCount () {
1497 if (countVideos !== true) return Promise.resolve(undefined)
1498
1499 const countOptions = Object.assign({}, options, { isCount: true })
1500 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1501
1502 return queryBuilder.countVideoIds(countOptions)
1503 }
1504
1505 function getModels () {
1506 if (options.count === 0) return Promise.resolve([])
1507
1508 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1509
1510 return queryBuilder.queryVideos(options)
1511 }
1512
1513 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1514
1515 return {
1516 data: rows,
1517 total: count
1518 }
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 }