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