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