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