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