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