]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame_incremental - server/models/video/video.ts
Add live and viewers otel metrics
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
... / ...
CommitLineData
1import Bluebird from 'bluebird'
2import { remove } from 'fs-extra'
3import { maxBy, minBy } from 'lodash'
4import { join } from 'path'
5import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
6import {
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'
27import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
28import { LiveManager } from '@server/lib/live/live-manager'
29import { removeHLSFileObjectStorage, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
30import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths'
31import { VideoPathManager } from '@server/lib/video-path-manager'
32import { getServerActor } from '@server/models/application/application'
33import { ModelCache } from '@server/models/model-cache'
34import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
35import { ffprobePromise, getAudioStream, uuidToShort } from '@shared/extra-utils'
36import {
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'
51import { AttributesOnly } from '@shared/typescript-utils'
52import { peertubeTruncate } from '../../helpers/core-utils'
53import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
54import { exists, isBooleanValid } from '../../helpers/custom-validators/misc'
55import {
56 isVideoDescriptionValid,
57 isVideoDurationValid,
58 isVideoNameValid,
59 isVideoPrivacyValid,
60 isVideoStateValid,
61 isVideoSupportValid
62} from '../../helpers/custom-validators/videos'
63import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
64import { logger } from '../../helpers/logger'
65import { CONFIG } from '../../initializers/config'
66import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
67import { sendDeleteVideo } from '../../lib/activitypub/send'
68import {
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'
93import { MThumbnail } from '../../types/models/video/thumbnail'
94import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
95import { VideoAbuseModel } from '../abuse/video-abuse'
96import { AccountModel } from '../account/account'
97import { AccountVideoRateModel } from '../account/account-video-rate'
98import { ActorModel } from '../actor/actor'
99import { ActorImageModel } from '../actor/actor-image'
100import { VideoRedundancyModel } from '../redundancy/video-redundancy'
101import { ServerModel } from '../server/server'
102import { TrackerModel } from '../server/tracker'
103import { VideoTrackerModel } from '../server/video-tracker'
104import { setAsUpdated } from '../shared'
105import { UserModel } from '../user/user'
106import { UserVideoHistoryModel } from '../user/user-video-history'
107import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
108import { VideoViewModel } from '../view/video-view'
109import {
110 videoFilesModelToFormattedJSON,
111 VideoFormattingJSONOptions,
112 videoModelToActivityPubObject,
113 videoModelToFormattedDetailsJSON,
114 videoModelToFormattedJSON
115} from './formatter/video-format-utils'
116import { ScheduleVideoUpdateModel } from './schedule-video-update'
117import {
118 BuildVideosListQueryOptions,
119 DisplayOnlyForFollowerOptions,
120 VideoModelGetQueryBuilder,
121 VideosIdListQueryBuilder,
122 VideosModelListQueryBuilder
123} from './sql/video'
124import { TagModel } from './tag'
125import { ThumbnailModel } from './thumbnail'
126import { VideoBlacklistModel } from './video-blacklist'
127import { VideoCaptionModel } from './video-caption'
128import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
129import { VideoCommentModel } from './video-comment'
130import { VideoFileModel } from './video-file'
131import { VideoImportModel } from './video-import'
132import { VideoJobInfoModel } from './video-job-info'
133import { VideoLiveModel } from './video-live'
134import { VideoPlaylistElementModel } from './video-playlist-element'
135import { VideoShareModel } from './video-share'
136import { VideoSourceModel } from './video-source'
137import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
138import { VideoTagModel } from './video-tag'
139
140export 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
153export 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})
431export 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
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: forCount
1009 ? VideoChannelModel.unscoped()
1010 : VideoChannelModel,
1011 required: true,
1012 where: channelWhere,
1013 include: [
1014 {
1015 model: forCount
1016 ? AccountModel.unscoped()
1017 : AccountModel,
1018 where: {
1019 id: accountId
1020 },
1021 required: true
1022 }
1023 ]
1024 }
1025 ]
1026 }
1027
1028 return baseQuery
1029 }
1030
1031 const countQuery = buildBaseQuery(true)
1032 const findQuery = buildBaseQuery(false)
1033
1034 const findScopes: (string | ScopeOptions)[] = [
1035 ScopeNames.WITH_SCHEDULED_UPDATE,
1036 ScopeNames.WITH_BLACKLISTED,
1037 ScopeNames.WITH_THUMBNAILS
1038 ]
1039
1040 return Promise.all([
1041 VideoModel.count(countQuery),
1042 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1043 ]).then(([ count, rows ]) => {
1044 return {
1045 data: rows,
1046 total: count
1047 }
1048 })
1049 }
1050
1051 static async listForApi (options: {
1052 start: number
1053 count: number
1054 sort: string
1055
1056 nsfw: boolean
1057 isLive?: boolean
1058 isLocal?: boolean
1059 include?: VideoInclude
1060
1061 hasFiles?: boolean // default false
1062 hasWebtorrentFiles?: boolean
1063 hasHLSFiles?: boolean
1064
1065 categoryOneOf?: number[]
1066 licenceOneOf?: number[]
1067 languageOneOf?: string[]
1068 tagsOneOf?: string[]
1069 tagsAllOf?: string[]
1070 privacyOneOf?: VideoPrivacy[]
1071
1072 accountId?: number
1073 videoChannelId?: number
1074
1075 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1076
1077 videoPlaylistId?: number
1078
1079 trendingDays?: number
1080
1081 user?: MUserAccountId
1082 historyOfUser?: MUserId
1083
1084 countVideos?: boolean
1085
1086 search?: string
1087 }) {
1088 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1089 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1090
1091 const trendingDays = options.sort.endsWith('trending')
1092 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1093 : undefined
1094
1095 let trendingAlgorithm: string
1096 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1097 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1098
1099 const serverActor = await getServerActor()
1100
1101 const queryOptions = {
1102 ...pick(options, [
1103 'start',
1104 'count',
1105 'sort',
1106 'nsfw',
1107 'isLive',
1108 'categoryOneOf',
1109 'licenceOneOf',
1110 'languageOneOf',
1111 'tagsOneOf',
1112 'tagsAllOf',
1113 'privacyOneOf',
1114 'isLocal',
1115 'include',
1116 'displayOnlyForFollower',
1117 'hasFiles',
1118 'accountId',
1119 'videoChannelId',
1120 'videoPlaylistId',
1121 'user',
1122 'historyOfUser',
1123 'hasHLSFiles',
1124 'hasWebtorrentFiles',
1125 'search'
1126 ]),
1127
1128 serverAccountIdForBlock: serverActor.Account.id,
1129 trendingDays,
1130 trendingAlgorithm
1131 }
1132
1133 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1134 }
1135
1136 static async searchAndPopulateAccountAndServer (options: {
1137 start: number
1138 count: number
1139 sort: string
1140
1141 nsfw?: boolean
1142 isLive?: boolean
1143 isLocal?: boolean
1144 include?: VideoInclude
1145
1146 categoryOneOf?: number[]
1147 licenceOneOf?: number[]
1148 languageOneOf?: string[]
1149 tagsOneOf?: string[]
1150 tagsAllOf?: string[]
1151 privacyOneOf?: VideoPrivacy[]
1152
1153 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1154
1155 user?: MUserAccountId
1156
1157 hasWebtorrentFiles?: boolean
1158 hasHLSFiles?: boolean
1159
1160 search?: string
1161
1162 host?: string
1163 startDate?: string // ISO 8601
1164 endDate?: string // ISO 8601
1165 originallyPublishedStartDate?: string
1166 originallyPublishedEndDate?: string
1167
1168 durationMin?: number // seconds
1169 durationMax?: number // seconds
1170 uuids?: string[]
1171 }) {
1172 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1173 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1174
1175 const serverActor = await getServerActor()
1176
1177 const queryOptions = {
1178 ...pick(options, [
1179 'include',
1180 'nsfw',
1181 'isLive',
1182 'categoryOneOf',
1183 'licenceOneOf',
1184 'languageOneOf',
1185 'tagsOneOf',
1186 'tagsAllOf',
1187 'privacyOneOf',
1188 'user',
1189 'isLocal',
1190 'host',
1191 'start',
1192 'count',
1193 'sort',
1194 'startDate',
1195 'endDate',
1196 'originallyPublishedStartDate',
1197 'originallyPublishedEndDate',
1198 'durationMin',
1199 'durationMax',
1200 'hasHLSFiles',
1201 'hasWebtorrentFiles',
1202 'uuids',
1203 'search',
1204 'displayOnlyForFollower'
1205 ]),
1206 serverAccountIdForBlock: serverActor.Account.id
1207 }
1208
1209 return VideoModel.getAvailableForApi(queryOptions)
1210 }
1211
1212 static countLives (options: {
1213 remote: boolean
1214 mode: 'published' | 'not-ended'
1215 }) {
1216 const query = {
1217 where: {
1218 remote: options.remote,
1219 isLive: true,
1220 state: options.mode === 'not-ended'
1221 ? { [Op.ne]: VideoState.LIVE_ENDED }
1222 : { [Op.eq]: VideoState.PUBLISHED }
1223 }
1224 }
1225
1226 return VideoModel.count(query)
1227 }
1228
1229 static countVideosUploadedByUserSince (userId: number, since: Date) {
1230 const options = {
1231 include: [
1232 {
1233 model: VideoChannelModel.unscoped(),
1234 required: true,
1235 include: [
1236 {
1237 model: AccountModel.unscoped(),
1238 required: true,
1239 include: [
1240 {
1241 model: UserModel.unscoped(),
1242 required: true,
1243 where: {
1244 id: userId
1245 }
1246 }
1247 ]
1248 }
1249 ]
1250 }
1251 ],
1252 where: {
1253 createdAt: {
1254 [Op.gte]: since
1255 }
1256 }
1257 }
1258
1259 return VideoModel.unscoped().count(options)
1260 }
1261
1262 static countLivesOfAccount (accountId: number) {
1263 const options = {
1264 where: {
1265 remote: false,
1266 isLive: true,
1267 state: {
1268 [Op.ne]: VideoState.LIVE_ENDED
1269 }
1270 },
1271 include: [
1272 {
1273 required: true,
1274 model: VideoChannelModel.unscoped(),
1275 where: {
1276 accountId
1277 }
1278 }
1279 ]
1280 }
1281
1282 return VideoModel.count(options)
1283 }
1284
1285 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1286 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1287
1288 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1289 }
1290
1291 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1292 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1293
1294 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1295 }
1296
1297 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1298 const fun = () => {
1299 const query = {
1300 where: buildWhereIdOrUUID(id),
1301 transaction: t
1302 }
1303
1304 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1305 }
1306
1307 return ModelCache.Instance.doCache({
1308 cacheType: 'load-video-immutable-id',
1309 key: '' + id,
1310 deleteKey: 'video',
1311 fun
1312 })
1313 }
1314
1315 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1316 const fun = () => {
1317 const query: FindOptions = {
1318 where: {
1319 url
1320 },
1321 transaction
1322 }
1323
1324 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1325 }
1326
1327 return ModelCache.Instance.doCache({
1328 cacheType: 'load-video-immutable-url',
1329 key: url,
1330 deleteKey: 'video',
1331 fun
1332 })
1333 }
1334
1335 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1336 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1337
1338 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1339 }
1340
1341 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1342 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1343
1344 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1345 }
1346
1347 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1348 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1349
1350 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1351 }
1352
1353 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1354 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1355
1356 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1357 }
1358
1359 static loadFull (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1360 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1361
1362 return queryBuilder.queryVideo({ id, transaction: t, type: 'full', userId })
1363 }
1364
1365 static loadForGetAPI (parameters: {
1366 id: number | string
1367 transaction?: Transaction
1368 userId?: number
1369 }): Promise<MVideoDetails> {
1370 const { id, transaction, userId } = parameters
1371 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1372
1373 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1374 }
1375
1376 static async getStats () {
1377 const serverActor = await getServerActor()
1378
1379 let totalLocalVideoViews = await VideoModel.sum('views', {
1380 where: {
1381 remote: false
1382 }
1383 })
1384
1385 // Sequelize could return null...
1386 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1387
1388 const baseOptions = {
1389 start: 0,
1390 count: 0,
1391 sort: '-publishedAt',
1392 nsfw: null,
1393 displayOnlyForFollower: {
1394 actorId: serverActor.id,
1395 orLocalVideos: true
1396 }
1397 }
1398
1399 const { total: totalLocalVideos } = await VideoModel.listForApi({
1400 ...baseOptions,
1401
1402 isLocal: true
1403 })
1404
1405 const { total: totalVideos } = await VideoModel.listForApi(baseOptions)
1406
1407 return {
1408 totalLocalVideos,
1409 totalLocalVideoViews,
1410 totalVideos
1411 }
1412 }
1413
1414 static incrementViews (id: number, views: number) {
1415 return VideoModel.increment('views', {
1416 by: views,
1417 where: {
1418 id
1419 }
1420 })
1421 }
1422
1423 static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
1424 const field = type === 'like'
1425 ? 'likes'
1426 : 'dislikes'
1427
1428 const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
1429
1430 return AccountVideoRateModel.sequelize.query(rawQuery, {
1431 transaction: t,
1432 replacements: { videoId, rateType: type, count },
1433 type: QueryTypes.UPDATE
1434 })
1435 }
1436
1437 static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
1438 const field = type === 'like'
1439 ? 'likes'
1440 : 'dislikes'
1441
1442 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1443 '(' +
1444 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1445 ') ' +
1446 'WHERE "video"."id" = :videoId'
1447
1448 return AccountVideoRateModel.sequelize.query(rawQuery, {
1449 transaction: t,
1450 replacements: { videoId, rateType: type },
1451 type: QueryTypes.UPDATE
1452 })
1453 }
1454
1455 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1456 // Instances only share videos
1457 const query = 'SELECT 1 FROM "videoShare" ' +
1458 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1459 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1460 'LIMIT 1'
1461
1462 const options = {
1463 type: QueryTypes.SELECT as QueryTypes.SELECT,
1464 bind: { followerActorId, videoId },
1465 raw: true
1466 }
1467
1468 return VideoModel.sequelize.query(query, options)
1469 .then(results => results.length === 1)
1470 }
1471
1472 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1473 const options = {
1474 where: {
1475 channelId: ofChannel.id
1476 },
1477 transaction: t
1478 }
1479
1480 return VideoModel.update({ support: ofChannel.support }, options)
1481 }
1482
1483 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1484 const query = {
1485 attributes: [ 'id' ],
1486 where: {
1487 channelId: videoChannel.id
1488 }
1489 }
1490
1491 return VideoModel.findAll(query)
1492 .then(videos => videos.map(v => v.id))
1493 }
1494
1495 // threshold corresponds to how many video the field should have to be returned
1496 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1497 const serverActor = await getServerActor()
1498
1499 const queryOptions: BuildVideosListQueryOptions = {
1500 attributes: [ `"${field}"` ],
1501 group: `GROUP BY "${field}"`,
1502 having: `HAVING COUNT("${field}") >= ${threshold}`,
1503 start: 0,
1504 sort: 'random',
1505 count,
1506 serverAccountIdForBlock: serverActor.Account.id,
1507 displayOnlyForFollower: {
1508 actorId: serverActor.id,
1509 orLocalVideos: true
1510 }
1511 }
1512
1513 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1514
1515 return queryBuilder.queryVideoIds(queryOptions)
1516 .then(rows => rows.map(r => r[field]))
1517 }
1518
1519 static buildTrendingQuery (trendingDays: number) {
1520 return {
1521 attributes: [],
1522 subQuery: false,
1523 model: VideoViewModel,
1524 required: false,
1525 where: {
1526 startDate: {
1527 // FIXME: ts error
1528 [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1529 }
1530 }
1531 }
1532 }
1533
1534 private static async getAvailableForApi (
1535 options: BuildVideosListQueryOptions,
1536 countVideos = true
1537 ): Promise<ResultList<VideoModel>> {
1538 function getCount () {
1539 if (countVideos !== true) return Promise.resolve(undefined)
1540
1541 const countOptions = Object.assign({}, options, { isCount: true })
1542 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1543
1544 return queryBuilder.countVideoIds(countOptions)
1545 }
1546
1547 function getModels () {
1548 if (options.count === 0) return Promise.resolve([])
1549
1550 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1551
1552 return queryBuilder.queryVideos(options)
1553 }
1554
1555 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1556
1557 return {
1558 data: rows,
1559 total: count
1560 }
1561 }
1562
1563 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1564 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1565 throw new Error('Try to filter all-local but user cannot see all videos')
1566 }
1567 }
1568
1569 private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
1570 if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1571 throw new Error('Try to choose video privacies but user cannot see all videos')
1572 }
1573 }
1574
1575 private static isPrivateInclude (include: VideoInclude) {
1576 return include & VideoInclude.BLACKLISTED ||
1577 include & VideoInclude.BLOCKED_OWNER ||
1578 include & VideoInclude.NOT_PUBLISHED_STATE
1579 }
1580
1581 isBlacklisted () {
1582 return !!this.VideoBlacklist
1583 }
1584
1585 isBlocked () {
1586 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1587 }
1588
1589 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1590 // We first transcode to WebTorrent format, so try this array first
1591 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1592 const file = fun(this.VideoFiles, file => file.resolution)
1593
1594 return Object.assign(file, { Video: this })
1595 }
1596
1597 // No webtorrent files, try with streaming playlist files
1598 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1599 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1600
1601 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1602 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1603 }
1604
1605 return undefined
1606 }
1607
1608 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1609 return this.getQualityFileBy(maxBy)
1610 }
1611
1612 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1613 return this.getQualityFileBy(minBy)
1614 }
1615
1616 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1617 if (Array.isArray(this.VideoFiles) === false) return undefined
1618
1619 const file = this.VideoFiles.find(f => f.resolution === resolution)
1620 if (!file) return undefined
1621
1622 return Object.assign(file, { Video: this })
1623 }
1624
1625 hasWebTorrentFiles () {
1626 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1627 }
1628
1629 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1630 thumbnail.videoId = this.id
1631
1632 const savedThumbnail = await thumbnail.save({ transaction })
1633
1634 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1635
1636 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1637 this.Thumbnails.push(savedThumbnail)
1638 }
1639
1640 getMiniature () {
1641 if (Array.isArray(this.Thumbnails) === false) return undefined
1642
1643 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1644 }
1645
1646 hasPreview () {
1647 return !!this.getPreview()
1648 }
1649
1650 getPreview () {
1651 if (Array.isArray(this.Thumbnails) === false) return undefined
1652
1653 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1654 }
1655
1656 isOwned () {
1657 return this.remote === false
1658 }
1659
1660 getWatchStaticPath () {
1661 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1662 }
1663
1664 getEmbedStaticPath () {
1665 return buildVideoEmbedPath(this)
1666 }
1667
1668 getMiniatureStaticPath () {
1669 const thumbnail = this.getMiniature()
1670 if (!thumbnail) return null
1671
1672 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1673 }
1674
1675 getPreviewStaticPath () {
1676 const preview = this.getPreview()
1677 if (!preview) return null
1678
1679 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1680 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1681 }
1682
1683 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1684 return videoModelToFormattedJSON(this, options)
1685 }
1686
1687 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1688 return videoModelToFormattedDetailsJSON(this)
1689 }
1690
1691 getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
1692 let files: VideoFile[] = []
1693
1694 if (Array.isArray(this.VideoFiles)) {
1695 const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet)
1696 files = files.concat(result)
1697 }
1698
1699 for (const p of (this.VideoStreamingPlaylists || [])) {
1700 const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet)
1701 files = files.concat(result)
1702 }
1703
1704 return files
1705 }
1706
1707 toActivityPubObject (this: MVideoAP): VideoObject {
1708 return videoModelToActivityPubObject(this)
1709 }
1710
1711 getTruncatedDescription () {
1712 if (!this.description) return null
1713
1714 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1715 return peertubeTruncate(this.description, { length: maxLength })
1716 }
1717
1718 getAllFiles () {
1719 let files: MVideoFile[] = []
1720
1721 if (Array.isArray(this.VideoFiles)) {
1722 files = files.concat(this.VideoFiles)
1723 }
1724
1725 if (Array.isArray(this.VideoStreamingPlaylists)) {
1726 for (const p of this.VideoStreamingPlaylists) {
1727 if (Array.isArray(p.VideoFiles)) {
1728 files = files.concat(p.VideoFiles)
1729 }
1730 }
1731 }
1732
1733 return files
1734 }
1735
1736 probeMaxQualityFile () {
1737 const file = this.getMaxQualityFile()
1738 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1739
1740 return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
1741 const probe = await ffprobePromise(originalFilePath)
1742
1743 const { audioStream } = await getAudioStream(originalFilePath, probe)
1744
1745 return {
1746 audioStream,
1747
1748 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1749 }
1750 })
1751 }
1752
1753 getDescriptionAPIPath () {
1754 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1755 }
1756
1757 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1758 if (!this.VideoStreamingPlaylists) return undefined
1759
1760 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1761 if (!playlist) return undefined
1762
1763 playlist.Video = this
1764
1765 return playlist
1766 }
1767
1768 setHLSPlaylist (playlist: MStreamingPlaylist) {
1769 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1770
1771 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1772 this.VideoStreamingPlaylists = toAdd
1773 return
1774 }
1775
1776 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1777 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1778 .concat(toAdd)
1779 }
1780
1781 removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
1782 const filePath = isRedundancy
1783 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1784 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1785
1786 const promises: Promise<any>[] = [ remove(filePath) ]
1787 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1788
1789 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1790 promises.push(removeWebTorrentObjectStorage(videoFile))
1791 }
1792
1793 return Promise.all(promises)
1794 }
1795
1796 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1797 const directoryPath = isRedundancy
1798 ? getHLSRedundancyDirectory(this)
1799 : getHLSDirectory(this)
1800
1801 await remove(directoryPath)
1802
1803 if (isRedundancy !== true) {
1804 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1805 streamingPlaylistWithFiles.Video = this
1806
1807 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1808 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1809 }
1810
1811 // Remove physical files and torrents
1812 await Promise.all(
1813 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1814 )
1815
1816 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1817 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1818 }
1819 }
1820 }
1821
1822 async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
1823 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename)
1824 await videoFile.removeTorrent()
1825 await remove(filePath)
1826
1827 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1828 await removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), videoFile.filename)
1829 }
1830 }
1831
1832 async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
1833 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename)
1834 await remove(filePath)
1835
1836 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1837 await removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), filename)
1838 }
1839 }
1840
1841 isOutdated () {
1842 if (this.isOwned()) return false
1843
1844 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1845 }
1846
1847 hasPrivacyForFederation () {
1848 return isPrivacyForFederation(this.privacy)
1849 }
1850
1851 hasStateForFederation () {
1852 return isStateForFederation(this.state)
1853 }
1854
1855 isNewVideo (newPrivacy: VideoPrivacy) {
1856 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1857 }
1858
1859 setAsRefreshed (transaction?: Transaction) {
1860 return setAsUpdated('video', this.id, transaction)
1861 }
1862
1863 requiresAuth () {
1864 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
1865 }
1866
1867 setPrivacy (newPrivacy: VideoPrivacy) {
1868 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
1869 this.publishedAt = new Date()
1870 }
1871
1872 this.privacy = newPrivacy
1873 }
1874
1875 isConfidential () {
1876 return this.privacy === VideoPrivacy.PRIVATE ||
1877 this.privacy === VideoPrivacy.UNLISTED ||
1878 this.privacy === VideoPrivacy.INTERNAL
1879 }
1880
1881 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
1882 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
1883
1884 this.state = newState
1885
1886 if (this.state === VideoState.PUBLISHED && isNewVideo) {
1887 this.publishedAt = new Date()
1888 }
1889
1890 await this.save({ transaction })
1891 }
1892
1893 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1894 return Math.ceil((videoFile.size * 8) / this.duration)
1895 }
1896
1897 getTrackerUrls () {
1898 if (this.isOwned()) {
1899 return [
1900 WEBSERVER.URL + '/tracker/announce',
1901 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
1902 ]
1903 }
1904
1905 return this.Trackers.map(t => t.url)
1906 }
1907}