]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Correctly delete live files from object storage
[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 { removeHLSFileObjectStorageByFilename, 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 { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
34 import { getServerActor } from '@server/models/application/application'
35 import { ModelCache } from '@server/models/model-cache'
36 import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
37 import { ffprobePromise, getAudioStream, uuidToShort } from '@shared/extra-utils'
38 import {
39 ResultList,
40 ThumbnailType,
41 UserRight,
42 Video,
43 VideoDetails,
44 VideoFile,
45 VideoInclude,
46 VideoObject,
47 VideoPrivacy,
48 VideoRateType,
49 VideoState,
50 VideoStorage,
51 VideoStreamingPlaylistType
52 } from '@shared/models'
53 import { AttributesOnly } from '@shared/typescript-utils'
54 import { peertubeTruncate } from '../../helpers/core-utils'
55 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
56 import { exists, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
57 import {
58 isVideoDescriptionValid,
59 isVideoDurationValid,
60 isVideoNameValid,
61 isVideoPrivacyValid,
62 isVideoStateValid,
63 isVideoSupportValid
64 } from '../../helpers/custom-validators/videos'
65 import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
66 import { logger } from '../../helpers/logger'
67 import { CONFIG } from '../../initializers/config'
68 import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
69 import { sendDeleteVideo } from '../../lib/activitypub/send'
70 import {
71 MChannel,
72 MChannelAccountDefault,
73 MChannelId,
74 MStreamingPlaylist,
75 MStreamingPlaylistFilesVideo,
76 MUserAccountId,
77 MUserId,
78 MVideo,
79 MVideoAccountLight,
80 MVideoAccountLightBlacklistAllFiles,
81 MVideoAP,
82 MVideoDetails,
83 MVideoFileVideo,
84 MVideoFormattable,
85 MVideoFormattableDetails,
86 MVideoForUser,
87 MVideoFullLight,
88 MVideoId,
89 MVideoImmutable,
90 MVideoThumbnail,
91 MVideoThumbnailBlacklist,
92 MVideoWithAllFiles,
93 MVideoWithFile
94 } from '../../types/models'
95 import { MThumbnail } from '../../types/models/video/thumbnail'
96 import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
97 import { VideoAbuseModel } from '../abuse/video-abuse'
98 import { AccountModel } from '../account/account'
99 import { AccountVideoRateModel } from '../account/account-video-rate'
100 import { ActorModel } from '../actor/actor'
101 import { ActorImageModel } from '../actor/actor-image'
102 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
103 import { ServerModel } from '../server/server'
104 import { TrackerModel } from '../server/tracker'
105 import { VideoTrackerModel } from '../server/video-tracker'
106 import { setAsUpdated } from '../shared'
107 import { UserModel } from '../user/user'
108 import { UserVideoHistoryModel } from '../user/user-video-history'
109 import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
110 import { VideoViewModel } from '../view/video-view'
111 import {
112 videoFilesModelToFormattedJSON,
113 VideoFormattingJSONOptions,
114 videoModelToActivityPubObject,
115 videoModelToFormattedDetailsJSON,
116 videoModelToFormattedJSON
117 } from './formatter/video-format-utils'
118 import { ScheduleVideoUpdateModel } from './schedule-video-update'
119 import {
120 BuildVideosListQueryOptions,
121 DisplayOnlyForFollowerOptions,
122 VideoModelGetQueryBuilder,
123 VideosIdListQueryBuilder,
124 VideosModelListQueryBuilder
125 } from './sql/video'
126 import { TagModel } from './tag'
127 import { ThumbnailModel } from './thumbnail'
128 import { VideoBlacklistModel } from './video-blacklist'
129 import { VideoCaptionModel } from './video-caption'
130 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
131 import { VideoCommentModel } from './video-comment'
132 import { VideoFileModel } from './video-file'
133 import { VideoImportModel } from './video-import'
134 import { VideoJobInfoModel } from './video-job-info'
135 import { VideoLiveModel } from './video-live'
136 import { VideoPlaylistElementModel } from './video-playlist-element'
137 import { VideoShareModel } from './video-share'
138 import { VideoSourceModel } from './video-source'
139 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
140 import { VideoTagModel } from './video-tag'
141
142 export enum ScopeNames {
143 FOR_API = 'FOR_API',
144 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
145 WITH_TAGS = 'WITH_TAGS',
146 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES',
147 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
148 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
149 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
150 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
151 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
152 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
153 }
154
155 export type ForAPIOptions = {
156 ids?: number[]
157
158 videoPlaylistId?: number
159
160 withAccountBlockerIds?: number[]
161 }
162
163 @Scopes(() => ({
164 [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: {
165 attributes: [ 'id', 'url', 'uuid', 'remote' ]
166 },
167 [ScopeNames.FOR_API]: (options: ForAPIOptions) => {
168 const include: Includeable[] = [
169 {
170 model: VideoChannelModel.scope({
171 method: [
172 VideoChannelScopeNames.SUMMARY, {
173 withAccount: true,
174 withAccountBlockerIds: options.withAccountBlockerIds
175 } as SummaryOptions
176 ]
177 }),
178 required: true
179 },
180 {
181 attributes: [ 'type', 'filename' ],
182 model: ThumbnailModel,
183 required: false
184 }
185 ]
186
187 const query: FindOptions = {}
188
189 if (options.ids) {
190 query.where = {
191 id: {
192 [Op.in]: options.ids
193 }
194 }
195 }
196
197 if (options.videoPlaylistId) {
198 include.push({
199 model: VideoPlaylistElementModel.unscoped(),
200 required: true,
201 where: {
202 videoPlaylistId: options.videoPlaylistId
203 }
204 })
205 }
206
207 query.include = include
208
209 return query
210 },
211 [ScopeNames.WITH_THUMBNAILS]: {
212 include: [
213 {
214 model: ThumbnailModel,
215 required: false
216 }
217 ]
218 },
219 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
220 include: [
221 {
222 model: VideoChannelModel.unscoped(),
223 required: true,
224 include: [
225 {
226 attributes: {
227 exclude: [ 'privateKey', 'publicKey' ]
228 },
229 model: ActorModel.unscoped(),
230 required: true,
231 include: [
232 {
233 attributes: [ 'host' ],
234 model: ServerModel.unscoped(),
235 required: false
236 },
237 {
238 model: ActorImageModel,
239 as: 'Avatars',
240 required: false
241 }
242 ]
243 },
244 {
245 model: AccountModel.unscoped(),
246 required: true,
247 include: [
248 {
249 model: ActorModel.unscoped(),
250 attributes: {
251 exclude: [ 'privateKey', 'publicKey' ]
252 },
253 required: true,
254 include: [
255 {
256 attributes: [ 'host' ],
257 model: ServerModel.unscoped(),
258 required: false
259 },
260 {
261 model: ActorImageModel,
262 as: 'Avatars',
263 required: false
264 }
265 ]
266 }
267 ]
268 }
269 ]
270 }
271 ]
272 },
273 [ScopeNames.WITH_TAGS]: {
274 include: [ TagModel ]
275 },
276 [ScopeNames.WITH_BLACKLISTED]: {
277 include: [
278 {
279 attributes: [ 'id', 'reason', 'unfederated' ],
280 model: VideoBlacklistModel,
281 required: false
282 }
283 ]
284 },
285 [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => {
286 let subInclude: any[] = []
287
288 if (withRedundancies === true) {
289 subInclude = [
290 {
291 attributes: [ 'fileUrl' ],
292 model: VideoRedundancyModel.unscoped(),
293 required: false
294 }
295 ]
296 }
297
298 return {
299 include: [
300 {
301 model: VideoFileModel,
302 separate: true,
303 required: false,
304 include: subInclude
305 }
306 ]
307 }
308 },
309 [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => {
310 const subInclude: IncludeOptions[] = [
311 {
312 model: VideoFileModel,
313 required: false
314 }
315 ]
316
317 if (withRedundancies === true) {
318 subInclude.push({
319 attributes: [ 'fileUrl' ],
320 model: VideoRedundancyModel.unscoped(),
321 required: false
322 })
323 }
324
325 return {
326 include: [
327 {
328 model: VideoStreamingPlaylistModel.unscoped(),
329 required: false,
330 separate: true,
331 include: subInclude
332 }
333 ]
334 }
335 },
336 [ScopeNames.WITH_SCHEDULED_UPDATE]: {
337 include: [
338 {
339 model: ScheduleVideoUpdateModel.unscoped(),
340 required: false
341 }
342 ]
343 },
344 [ScopeNames.WITH_USER_HISTORY]: (userId: number) => {
345 return {
346 include: [
347 {
348 attributes: [ 'currentTime' ],
349 model: UserVideoHistoryModel.unscoped(),
350 required: false,
351 where: {
352 userId
353 }
354 }
355 ]
356 }
357 }
358 }))
359 @Table({
360 tableName: 'video',
361 indexes: [
362 buildTrigramSearchIndex('video_name_trigram', 'name'),
363
364 { fields: [ 'createdAt' ] },
365 {
366 fields: [
367 { name: 'publishedAt', order: 'DESC' },
368 { name: 'id', order: 'ASC' }
369 ]
370 },
371 { fields: [ 'duration' ] },
372 {
373 fields: [
374 { name: 'views', order: 'DESC' },
375 { name: 'id', order: 'ASC' }
376 ]
377 },
378 { fields: [ 'channelId' ] },
379 {
380 fields: [ 'originallyPublishedAt' ],
381 where: {
382 originallyPublishedAt: {
383 [Op.ne]: null
384 }
385 }
386 },
387 {
388 fields: [ 'category' ], // We don't care videos with an unknown category
389 where: {
390 category: {
391 [Op.ne]: null
392 }
393 }
394 },
395 {
396 fields: [ 'licence' ], // We don't care videos with an unknown licence
397 where: {
398 licence: {
399 [Op.ne]: null
400 }
401 }
402 },
403 {
404 fields: [ 'language' ], // We don't care videos with an unknown language
405 where: {
406 language: {
407 [Op.ne]: null
408 }
409 }
410 },
411 {
412 fields: [ 'nsfw' ], // Most of the videos are not NSFW
413 where: {
414 nsfw: true
415 }
416 },
417 {
418 fields: [ 'remote' ], // Only index local videos
419 where: {
420 remote: false
421 }
422 },
423 {
424 fields: [ 'uuid' ],
425 unique: true
426 },
427 {
428 fields: [ 'url' ],
429 unique: true
430 }
431 ]
432 })
433 export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
434
435 @AllowNull(false)
436 @Default(DataType.UUIDV4)
437 @IsUUID(4)
438 @Column(DataType.UUID)
439 uuid: string
440
441 @AllowNull(false)
442 @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
443 @Column
444 name: string
445
446 @AllowNull(true)
447 @Default(null)
448 @Column
449 category: number
450
451 @AllowNull(true)
452 @Default(null)
453 @Column
454 licence: number
455
456 @AllowNull(true)
457 @Default(null)
458 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
459 language: string
460
461 @AllowNull(false)
462 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
463 @Column
464 privacy: VideoPrivacy
465
466 @AllowNull(false)
467 @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
468 @Column
469 nsfw: boolean
470
471 @AllowNull(true)
472 @Default(null)
473 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
474 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
475 description: string
476
477 @AllowNull(true)
478 @Default(null)
479 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
480 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
481 support: string
482
483 @AllowNull(false)
484 @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
485 @Column
486 duration: number
487
488 @AllowNull(false)
489 @Default(0)
490 @IsInt
491 @Min(0)
492 @Column
493 views: number
494
495 @AllowNull(false)
496 @Default(0)
497 @IsInt
498 @Min(0)
499 @Column
500 likes: number
501
502 @AllowNull(false)
503 @Default(0)
504 @IsInt
505 @Min(0)
506 @Column
507 dislikes: number
508
509 @AllowNull(false)
510 @Column
511 remote: boolean
512
513 @AllowNull(false)
514 @Default(false)
515 @Column
516 isLive: boolean
517
518 @AllowNull(false)
519 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
520 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
521 url: string
522
523 @AllowNull(false)
524 @Column
525 commentsEnabled: boolean
526
527 @AllowNull(false)
528 @Column
529 downloadEnabled: boolean
530
531 @AllowNull(false)
532 @Column
533 waitTranscoding: boolean
534
535 @AllowNull(false)
536 @Default(null)
537 @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
538 @Column
539 state: VideoState
540
541 @CreatedAt
542 createdAt: Date
543
544 @UpdatedAt
545 updatedAt: Date
546
547 @AllowNull(false)
548 @Default(DataType.NOW)
549 @Column
550 publishedAt: Date
551
552 @AllowNull(true)
553 @Default(null)
554 @Column
555 originallyPublishedAt: Date
556
557 @ForeignKey(() => VideoChannelModel)
558 @Column
559 channelId: number
560
561 @BelongsTo(() => VideoChannelModel, {
562 foreignKey: {
563 allowNull: true
564 },
565 onDelete: 'cascade'
566 })
567 VideoChannel: VideoChannelModel
568
569 @BelongsToMany(() => TagModel, {
570 foreignKey: 'videoId',
571 through: () => VideoTagModel,
572 onDelete: 'CASCADE'
573 })
574 Tags: TagModel[]
575
576 @BelongsToMany(() => TrackerModel, {
577 foreignKey: 'videoId',
578 through: () => VideoTrackerModel,
579 onDelete: 'CASCADE'
580 })
581 Trackers: TrackerModel[]
582
583 @HasMany(() => ThumbnailModel, {
584 foreignKey: {
585 name: 'videoId',
586 allowNull: true
587 },
588 hooks: true,
589 onDelete: 'cascade'
590 })
591 Thumbnails: ThumbnailModel[]
592
593 @HasMany(() => VideoPlaylistElementModel, {
594 foreignKey: {
595 name: 'videoId',
596 allowNull: true
597 },
598 onDelete: 'set null'
599 })
600 VideoPlaylistElements: VideoPlaylistElementModel[]
601
602 @HasOne(() => VideoSourceModel, {
603 foreignKey: {
604 name: 'videoId',
605 allowNull: true
606 },
607 onDelete: 'CASCADE'
608 })
609 VideoSource: VideoSourceModel
610
611 @HasMany(() => VideoAbuseModel, {
612 foreignKey: {
613 name: 'videoId',
614 allowNull: true
615 },
616 onDelete: 'set null'
617 })
618 VideoAbuses: VideoAbuseModel[]
619
620 @HasMany(() => VideoFileModel, {
621 foreignKey: {
622 name: 'videoId',
623 allowNull: true
624 },
625 hooks: true,
626 onDelete: 'cascade'
627 })
628 VideoFiles: VideoFileModel[]
629
630 @HasMany(() => VideoStreamingPlaylistModel, {
631 foreignKey: {
632 name: 'videoId',
633 allowNull: false
634 },
635 hooks: true,
636 onDelete: 'cascade'
637 })
638 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
639
640 @HasMany(() => VideoShareModel, {
641 foreignKey: {
642 name: 'videoId',
643 allowNull: false
644 },
645 onDelete: 'cascade'
646 })
647 VideoShares: VideoShareModel[]
648
649 @HasMany(() => AccountVideoRateModel, {
650 foreignKey: {
651 name: 'videoId',
652 allowNull: false
653 },
654 onDelete: 'cascade'
655 })
656 AccountVideoRates: AccountVideoRateModel[]
657
658 @HasMany(() => VideoCommentModel, {
659 foreignKey: {
660 name: 'videoId',
661 allowNull: false
662 },
663 onDelete: 'cascade',
664 hooks: true
665 })
666 VideoComments: VideoCommentModel[]
667
668 @HasMany(() => VideoViewModel, {
669 foreignKey: {
670 name: 'videoId',
671 allowNull: false
672 },
673 onDelete: 'cascade'
674 })
675 VideoViews: VideoViewModel[]
676
677 @HasMany(() => UserVideoHistoryModel, {
678 foreignKey: {
679 name: 'videoId',
680 allowNull: false
681 },
682 onDelete: 'cascade'
683 })
684 UserVideoHistories: UserVideoHistoryModel[]
685
686 @HasOne(() => ScheduleVideoUpdateModel, {
687 foreignKey: {
688 name: 'videoId',
689 allowNull: false
690 },
691 onDelete: 'cascade'
692 })
693 ScheduleVideoUpdate: ScheduleVideoUpdateModel
694
695 @HasOne(() => VideoBlacklistModel, {
696 foreignKey: {
697 name: 'videoId',
698 allowNull: false
699 },
700 onDelete: 'cascade'
701 })
702 VideoBlacklist: VideoBlacklistModel
703
704 @HasOne(() => VideoLiveModel, {
705 foreignKey: {
706 name: 'videoId',
707 allowNull: false
708 },
709 onDelete: 'cascade'
710 })
711 VideoLive: VideoLiveModel
712
713 @HasOne(() => VideoImportModel, {
714 foreignKey: {
715 name: 'videoId',
716 allowNull: true
717 },
718 onDelete: 'set null'
719 })
720 VideoImport: VideoImportModel
721
722 @HasMany(() => VideoCaptionModel, {
723 foreignKey: {
724 name: 'videoId',
725 allowNull: false
726 },
727 onDelete: 'cascade',
728 hooks: true,
729 ['separate' as any]: true
730 })
731 VideoCaptions: VideoCaptionModel[]
732
733 @HasOne(() => VideoJobInfoModel, {
734 foreignKey: {
735 name: 'videoId',
736 allowNull: false
737 },
738 onDelete: 'cascade'
739 })
740 VideoJobInfo: VideoJobInfoModel
741
742 @BeforeDestroy
743 static async sendDelete (instance: MVideoAccountLight, options) {
744 if (!instance.isOwned()) return undefined
745
746 // Lazy load channels
747 if (!instance.VideoChannel) {
748 instance.VideoChannel = await instance.$get('VideoChannel', {
749 include: [
750 ActorModel,
751 AccountModel
752 ],
753 transaction: options.transaction
754 }) as MChannelAccountDefault
755 }
756
757 return sendDeleteVideo(instance, options.transaction)
758 }
759
760 @BeforeDestroy
761 static async removeFiles (instance: VideoModel, options) {
762 const tasks: Promise<any>[] = []
763
764 logger.info('Removing files of video %s.', instance.url)
765
766 if (instance.isOwned()) {
767 if (!Array.isArray(instance.VideoFiles)) {
768 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
769 }
770
771 // Remove physical files and torrents
772 instance.VideoFiles.forEach(file => {
773 tasks.push(instance.removeWebTorrentFile(file))
774 })
775
776 // Remove playlists file
777 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
778 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
779 }
780
781 for (const p of instance.VideoStreamingPlaylists) {
782 tasks.push(instance.removeStreamingPlaylistFiles(p))
783 }
784 }
785
786 // Do not wait video deletion because we could be in a transaction
787 Promise.all(tasks)
788 .then(() => logger.info('Removed files of video %s.', instance.url))
789 .catch(err => logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err }))
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 return playlist.withVideo(this)
1768 }
1769
1770 setHLSPlaylist (playlist: MStreamingPlaylist) {
1771 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1772
1773 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1774 this.VideoStreamingPlaylists = toAdd
1775 return
1776 }
1777
1778 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1779 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1780 .concat(toAdd)
1781 }
1782
1783 removeWebTorrentFile (videoFile: MVideoFile, isRedundancy = false) {
1784 const filePath = isRedundancy
1785 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1786 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1787
1788 const promises: Promise<any>[] = [ remove(filePath) ]
1789 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1790
1791 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1792 promises.push(removeWebTorrentObjectStorage(videoFile))
1793 }
1794
1795 return Promise.all(promises)
1796 }
1797
1798 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1799 const directoryPath = isRedundancy
1800 ? getHLSRedundancyDirectory(this)
1801 : getHLSDirectory(this)
1802
1803 await remove(directoryPath)
1804
1805 if (isRedundancy !== true) {
1806 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1807 streamingPlaylistWithFiles.Video = this
1808
1809 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1810 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1811 }
1812
1813 // Remove physical files and torrents
1814 await Promise.all(
1815 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1816 )
1817
1818 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1819 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1820 }
1821 }
1822 }
1823
1824 async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
1825 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename)
1826 await videoFile.removeTorrent()
1827 await remove(filePath)
1828
1829 const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
1830 await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
1831
1832 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1833 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
1834 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
1835 }
1836 }
1837
1838 async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
1839 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename)
1840 await remove(filePath)
1841
1842 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1843 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
1844 }
1845 }
1846
1847 isOutdated () {
1848 if (this.isOwned()) return false
1849
1850 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1851 }
1852
1853 hasPrivacyForFederation () {
1854 return isPrivacyForFederation(this.privacy)
1855 }
1856
1857 hasStateForFederation () {
1858 return isStateForFederation(this.state)
1859 }
1860
1861 isNewVideo (newPrivacy: VideoPrivacy) {
1862 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1863 }
1864
1865 setAsRefreshed (transaction?: Transaction) {
1866 return setAsUpdated('video', this.id, transaction)
1867 }
1868
1869 // ---------------------------------------------------------------------------
1870
1871 requiresAuth (options: {
1872 urlParamId: string
1873 checkBlacklist: boolean
1874 }) {
1875 const { urlParamId, checkBlacklist } = options
1876
1877 if (this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL) {
1878 return true
1879 }
1880
1881 if (this.privacy === VideoPrivacy.UNLISTED) {
1882 if (urlParamId && !isUUIDValid(urlParamId)) return true
1883
1884 return false
1885 }
1886
1887 if (checkBlacklist && this.VideoBlacklist) return true
1888
1889 if (this.privacy !== VideoPrivacy.PUBLIC) {
1890 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
1891 }
1892
1893 return false
1894 }
1895
1896 hasPrivateStaticPath () {
1897 return isVideoInPrivateDirectory(this.privacy)
1898 }
1899
1900 // ---------------------------------------------------------------------------
1901
1902 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
1903 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
1904
1905 this.state = newState
1906
1907 if (this.state === VideoState.PUBLISHED && isNewVideo) {
1908 this.publishedAt = new Date()
1909 }
1910
1911 await this.save({ transaction })
1912 }
1913
1914 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1915 if (!this.duration) throw new Error(`Cannot get bandwidth bits because video ${this.url} has duration of 0`)
1916
1917 return Math.ceil((videoFile.size * 8) / this.duration)
1918 }
1919
1920 getTrackerUrls () {
1921 if (this.isOwned()) {
1922 return [
1923 WEBSERVER.URL + '/tracker/announce',
1924 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
1925 ]
1926 }
1927
1928 return this.Trackers.map(t => t.url)
1929 }
1930 }