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