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