]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Merge branch 'release/4.0.0' into develop
[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 { 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 [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1492 }
1493 }
1494 }
1495 }
1496
1497 private static async getAvailableForApi (
1498 options: BuildVideosListQueryOptions,
1499 countVideos = true
1500 ): Promise<ResultList<VideoModel>> {
1501 function getCount () {
1502 if (countVideos !== true) return Promise.resolve(undefined)
1503
1504 const countOptions = Object.assign({}, options, { isCount: true })
1505 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1506
1507 return queryBuilder.countVideoIds(countOptions)
1508 }
1509
1510 function getModels () {
1511 if (options.count === 0) return Promise.resolve([])
1512
1513 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1514
1515 return queryBuilder.queryVideos(options)
1516 }
1517
1518 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1519
1520 return {
1521 data: rows,
1522 total: count
1523 }
1524 }
1525
1526 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1527 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1528 throw new Error('Try to filter all-local but user cannot see all videos')
1529 }
1530 }
1531
1532 private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
1533 if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1534 throw new Error('Try to choose video privacies but user cannot see all videos')
1535 }
1536 }
1537
1538 private static isPrivateInclude (include: VideoInclude) {
1539 return include & VideoInclude.BLACKLISTED ||
1540 include & VideoInclude.BLOCKED_OWNER ||
1541 include & VideoInclude.NOT_PUBLISHED_STATE
1542 }
1543
1544 isBlacklisted () {
1545 return !!this.VideoBlacklist
1546 }
1547
1548 isBlocked () {
1549 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1550 }
1551
1552 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1553 // We first transcode to WebTorrent format, so try this array first
1554 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1555 const file = fun(this.VideoFiles, file => file.resolution)
1556
1557 return Object.assign(file, { Video: this })
1558 }
1559
1560 // No webtorrent files, try with streaming playlist files
1561 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1562 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1563
1564 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1565 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1566 }
1567
1568 return undefined
1569 }
1570
1571 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1572 return this.getQualityFileBy(maxBy)
1573 }
1574
1575 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1576 return this.getQualityFileBy(minBy)
1577 }
1578
1579 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1580 if (Array.isArray(this.VideoFiles) === false) return undefined
1581
1582 const file = this.VideoFiles.find(f => f.resolution === resolution)
1583 if (!file) return undefined
1584
1585 return Object.assign(file, { Video: this })
1586 }
1587
1588 hasWebTorrentFiles () {
1589 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1590 }
1591
1592 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1593 thumbnail.videoId = this.id
1594
1595 const savedThumbnail = await thumbnail.save({ transaction })
1596
1597 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1598
1599 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1600 this.Thumbnails.push(savedThumbnail)
1601 }
1602
1603 getMiniature () {
1604 if (Array.isArray(this.Thumbnails) === false) return undefined
1605
1606 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1607 }
1608
1609 hasPreview () {
1610 return !!this.getPreview()
1611 }
1612
1613 getPreview () {
1614 if (Array.isArray(this.Thumbnails) === false) return undefined
1615
1616 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1617 }
1618
1619 isOwned () {
1620 return this.remote === false
1621 }
1622
1623 getWatchStaticPath () {
1624 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1625 }
1626
1627 getEmbedStaticPath () {
1628 return buildVideoEmbedPath(this)
1629 }
1630
1631 getMiniatureStaticPath () {
1632 const thumbnail = this.getMiniature()
1633 if (!thumbnail) return null
1634
1635 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1636 }
1637
1638 getPreviewStaticPath () {
1639 const preview = this.getPreview()
1640 if (!preview) return null
1641
1642 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1643 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1644 }
1645
1646 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1647 return videoModelToFormattedJSON(this, options)
1648 }
1649
1650 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1651 return videoModelToFormattedDetailsJSON(this)
1652 }
1653
1654 getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
1655 let files: VideoFile[] = []
1656
1657 if (Array.isArray(this.VideoFiles)) {
1658 const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet)
1659 files = files.concat(result)
1660 }
1661
1662 for (const p of (this.VideoStreamingPlaylists || [])) {
1663 const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet)
1664 files = files.concat(result)
1665 }
1666
1667 return files
1668 }
1669
1670 toActivityPubObject (this: MVideoAP): VideoObject {
1671 return videoModelToActivityPubObject(this)
1672 }
1673
1674 getTruncatedDescription () {
1675 if (!this.description) return null
1676
1677 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1678 return peertubeTruncate(this.description, { length: maxLength })
1679 }
1680
1681 getMaxQualityResolution () {
1682 const file = this.getMaxQualityFile()
1683 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1684
1685 return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), originalFilePath => {
1686 return getVideoFileResolution(originalFilePath)
1687 })
1688 }
1689
1690 getDescriptionAPIPath () {
1691 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1692 }
1693
1694 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1695 if (!this.VideoStreamingPlaylists) return undefined
1696
1697 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1698 if (!playlist) return undefined
1699
1700 playlist.Video = this
1701
1702 return playlist
1703 }
1704
1705 setHLSPlaylist (playlist: MStreamingPlaylist) {
1706 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1707
1708 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1709 this.VideoStreamingPlaylists = toAdd
1710 return
1711 }
1712
1713 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1714 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1715 .concat(toAdd)
1716 }
1717
1718 removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
1719 const filePath = isRedundancy
1720 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1721 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1722
1723 const promises: Promise<any>[] = [ remove(filePath) ]
1724 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1725
1726 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1727 promises.push(removeWebTorrentObjectStorage(videoFile))
1728 }
1729
1730 return Promise.all(promises)
1731 }
1732
1733 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1734 const directoryPath = isRedundancy
1735 ? getHLSRedundancyDirectory(this)
1736 : getHLSDirectory(this)
1737
1738 await remove(directoryPath)
1739
1740 if (isRedundancy !== true) {
1741 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1742 streamingPlaylistWithFiles.Video = this
1743
1744 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1745 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1746 }
1747
1748 // Remove physical files and torrents
1749 await Promise.all(
1750 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1751 )
1752
1753 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1754 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1755 }
1756 }
1757 }
1758
1759 isOutdated () {
1760 if (this.isOwned()) return false
1761
1762 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1763 }
1764
1765 hasPrivacyForFederation () {
1766 return isPrivacyForFederation(this.privacy)
1767 }
1768
1769 hasStateForFederation () {
1770 return isStateForFederation(this.state)
1771 }
1772
1773 isNewVideo (newPrivacy: VideoPrivacy) {
1774 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1775 }
1776
1777 setAsRefreshed (transaction?: Transaction) {
1778 return setAsUpdated('video', this.id, transaction)
1779 }
1780
1781 requiresAuth () {
1782 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
1783 }
1784
1785 setPrivacy (newPrivacy: VideoPrivacy) {
1786 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
1787 this.publishedAt = new Date()
1788 }
1789
1790 this.privacy = newPrivacy
1791 }
1792
1793 isConfidential () {
1794 return this.privacy === VideoPrivacy.PRIVATE ||
1795 this.privacy === VideoPrivacy.UNLISTED ||
1796 this.privacy === VideoPrivacy.INTERNAL
1797 }
1798
1799 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
1800 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
1801
1802 this.state = newState
1803
1804 if (this.state === VideoState.PUBLISHED && isNewVideo) {
1805 this.publishedAt = new Date()
1806 }
1807
1808 await this.save({ transaction })
1809 }
1810
1811 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1812 return Math.ceil((videoFile.size * 8) / this.duration)
1813 }
1814
1815 getTrackerUrls () {
1816 if (this.isOwned()) {
1817 return [
1818 WEBSERVER.URL + '/tracker/announce',
1819 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
1820 ]
1821 }
1822
1823 return this.Trackers.map(t => t.url)
1824 }
1825 }