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