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