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