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