]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Implement video comment list in admin
[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, 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/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 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
827
828 return LiveManager.Instance.stopSessionOf(instance.id)
829 }
830
831 @BeforeDestroy
832 static invalidateCache (instance: VideoModel) {
833 ModelCache.Instance.invalidateCache('video', instance.id)
834 }
835
836 @BeforeDestroy
837 static async saveEssentialDataToAbuses (instance: VideoModel, options) {
838 const tasks: Promise<any>[] = []
839
840 if (!Array.isArray(instance.VideoAbuses)) {
841 instance.VideoAbuses = await instance.$get('VideoAbuses')
842
843 if (instance.VideoAbuses.length === 0) return undefined
844 }
845
846 logger.info('Saving video abuses details of video %s.', instance.url)
847
848 const details = instance.toFormattedDetailsJSON()
849
850 for (const abuse of instance.VideoAbuses) {
851 abuse.deletedVideo = details
852 tasks.push(abuse.save({ transaction: options.transaction }))
853 }
854
855 Promise.all(tasks)
856 .catch(err => {
857 logger.error('Some errors when saving details of video %s in its abuses before destroy hook.', instance.uuid, { err })
858 })
859
860 return undefined
861 }
862
863 static listLocal (): Bluebird<MVideoWithAllFiles[]> {
864 const query = {
865 where: {
866 remote: false
867 }
868 }
869
870 return VideoModel.scope([
871 ScopeNames.WITH_WEBTORRENT_FILES,
872 ScopeNames.WITH_STREAMING_PLAYLISTS,
873 ScopeNames.WITH_THUMBNAILS
874 ]).findAll(query)
875 }
876
877 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
878 function getRawQuery (select: string) {
879 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
880 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
881 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
882 'WHERE "Account"."actorId" = ' + actorId
883 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
884 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
885 'WHERE "VideoShare"."actorId" = ' + actorId
886
887 return `(${queryVideo}) UNION (${queryVideoShare})`
888 }
889
890 const rawQuery = getRawQuery('"Video"."id"')
891 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
892
893 const query = {
894 distinct: true,
895 offset: start,
896 limit: count,
897 order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
898 where: {
899 id: {
900 [Op.in]: Sequelize.literal('(' + rawQuery + ')')
901 },
902 [Op.or]: getPrivaciesForFederation()
903 },
904 include: [
905 {
906 attributes: [ 'language', 'fileUrl' ],
907 model: VideoCaptionModel.unscoped(),
908 required: false
909 },
910 {
911 attributes: [ 'id', 'url' ],
912 model: VideoShareModel.unscoped(),
913 required: false,
914 // We only want videos shared by this actor
915 where: {
916 [Op.and]: [
917 {
918 id: {
919 [Op.not]: null
920 }
921 },
922 {
923 actorId
924 }
925 ]
926 },
927 include: [
928 {
929 attributes: [ 'id', 'url' ],
930 model: ActorModel.unscoped()
931 }
932 ]
933 },
934 {
935 model: VideoChannelModel.unscoped(),
936 required: true,
937 include: [
938 {
939 attributes: [ 'name' ],
940 model: AccountModel.unscoped(),
941 required: true,
942 include: [
943 {
944 attributes: [ 'id', 'url', 'followersUrl' ],
945 model: ActorModel.unscoped(),
946 required: true
947 }
948 ]
949 },
950 {
951 attributes: [ 'id', 'url', 'followersUrl' ],
952 model: ActorModel.unscoped(),
953 required: true
954 }
955 ]
956 },
957 {
958 model: VideoStreamingPlaylistModel.unscoped(),
959 required: false,
960 include: [
961 {
962 model: VideoFileModel,
963 required: false
964 }
965 ]
966 },
967 VideoLiveModel.unscoped(),
968 VideoFileModel,
969 TagModel
970 ]
971 }
972
973 return Bluebird.all([
974 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
975 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
976 ]).then(([ rows, totals ]) => {
977 // totals: totalVideos + totalVideoShares
978 let totalVideos = 0
979 let totalVideoShares = 0
980 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
981 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
982
983 const total = totalVideos + totalVideoShares
984 return {
985 data: rows,
986 total: total
987 }
988 })
989 }
990
991 static listPublishedLiveIds () {
992 const options = {
993 attributes: [ 'id' ],
994 where: {
995 isLive: true,
996 state: VideoState.PUBLISHED
997 }
998 }
999
1000 return VideoModel.findAll(options)
1001 .map(v => v.id)
1002 }
1003
1004 static listUserVideosForApi (
1005 accountId: number,
1006 start: number,
1007 count: number,
1008 sort: string,
1009 search?: string
1010 ) {
1011 function buildBaseQuery (): FindOptions {
1012 let baseQuery = {
1013 offset: start,
1014 limit: count,
1015 order: getVideoSort(sort),
1016 include: [
1017 {
1018 model: VideoChannelModel,
1019 required: true,
1020 include: [
1021 {
1022 model: AccountModel,
1023 where: {
1024 id: accountId
1025 },
1026 required: true
1027 }
1028 ]
1029 }
1030 ]
1031 }
1032
1033 if (search) {
1034 baseQuery = Object.assign(baseQuery, {
1035 where: {
1036 name: {
1037 [Op.iLike]: '%' + search + '%'
1038 }
1039 }
1040 })
1041 }
1042
1043 return baseQuery
1044 }
1045
1046 const countQuery = buildBaseQuery()
1047 const findQuery = buildBaseQuery()
1048
1049 const findScopes: (string | ScopeOptions)[] = [
1050 ScopeNames.WITH_SCHEDULED_UPDATE,
1051 ScopeNames.WITH_BLACKLISTED,
1052 ScopeNames.WITH_THUMBNAILS
1053 ]
1054
1055 return Promise.all([
1056 VideoModel.count(countQuery),
1057 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1058 ]).then(([ count, rows ]) => {
1059 return {
1060 data: rows,
1061 total: count
1062 }
1063 })
1064 }
1065
1066 static async listForApi (options: {
1067 start: number
1068 count: number
1069 sort: string
1070 nsfw: boolean
1071 includeLocalVideos: boolean
1072 withFiles: boolean
1073 categoryOneOf?: number[]
1074 licenceOneOf?: number[]
1075 languageOneOf?: string[]
1076 tagsOneOf?: string[]
1077 tagsAllOf?: string[]
1078 filter?: VideoFilter
1079 accountId?: number
1080 videoChannelId?: number
1081 followerActorId?: number
1082 videoPlaylistId?: number
1083 trendingDays?: number
1084 user?: MUserAccountId
1085 historyOfUser?: MUserId
1086 countVideos?: boolean
1087 }) {
1088 if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1089 throw new Error('Try to filter all-local but no user has not the see all videos right')
1090 }
1091
1092 const trendingDays = options.sort.endsWith('trending')
1093 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1094 : undefined
1095
1096 const serverActor = await getServerActor()
1097
1098 // followerActorId === null has a meaning, so just check undefined
1099 const followerActorId = options.followerActorId !== undefined
1100 ? options.followerActorId
1101 : serverActor.id
1102
1103 const queryOptions = {
1104 start: options.start,
1105 count: options.count,
1106 sort: options.sort,
1107 followerActorId,
1108 serverAccountId: serverActor.Account.id,
1109 nsfw: options.nsfw,
1110 categoryOneOf: options.categoryOneOf,
1111 licenceOneOf: options.licenceOneOf,
1112 languageOneOf: options.languageOneOf,
1113 tagsOneOf: options.tagsOneOf,
1114 tagsAllOf: options.tagsAllOf,
1115 filter: options.filter,
1116 withFiles: options.withFiles,
1117 accountId: options.accountId,
1118 videoChannelId: options.videoChannelId,
1119 videoPlaylistId: options.videoPlaylistId,
1120 includeLocalVideos: options.includeLocalVideos,
1121 user: options.user,
1122 historyOfUser: options.historyOfUser,
1123 trendingDays
1124 }
1125
1126 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1127 }
1128
1129 static async searchAndPopulateAccountAndServer (options: {
1130 includeLocalVideos: boolean
1131 search?: string
1132 start?: number
1133 count?: number
1134 sort?: string
1135 startDate?: string // ISO 8601
1136 endDate?: string // ISO 8601
1137 originallyPublishedStartDate?: string
1138 originallyPublishedEndDate?: string
1139 nsfw?: boolean
1140 categoryOneOf?: number[]
1141 licenceOneOf?: number[]
1142 languageOneOf?: string[]
1143 tagsOneOf?: string[]
1144 tagsAllOf?: string[]
1145 durationMin?: number // seconds
1146 durationMax?: number // seconds
1147 user?: MUserAccountId
1148 filter?: VideoFilter
1149 }) {
1150 const serverActor = await getServerActor()
1151 const queryOptions = {
1152 followerActorId: serverActor.id,
1153 serverAccountId: serverActor.Account.id,
1154 includeLocalVideos: options.includeLocalVideos,
1155 nsfw: options.nsfw,
1156 categoryOneOf: options.categoryOneOf,
1157 licenceOneOf: options.licenceOneOf,
1158 languageOneOf: options.languageOneOf,
1159 tagsOneOf: options.tagsOneOf,
1160 tagsAllOf: options.tagsAllOf,
1161 user: options.user,
1162 filter: options.filter,
1163 start: options.start,
1164 count: options.count,
1165 sort: options.sort,
1166 startDate: options.startDate,
1167 endDate: options.endDate,
1168 originallyPublishedStartDate: options.originallyPublishedStartDate,
1169 originallyPublishedEndDate: options.originallyPublishedEndDate,
1170
1171 durationMin: options.durationMin,
1172 durationMax: options.durationMax,
1173
1174 search: options.search
1175 }
1176
1177 return VideoModel.getAvailableForApi(queryOptions)
1178 }
1179
1180 static countLocalLives () {
1181 const options = {
1182 where: {
1183 remote: false,
1184 isLive: true
1185 }
1186 }
1187
1188 return VideoModel.count(options)
1189 }
1190
1191 static countLivesOfAccount (accountId: number) {
1192 const options = {
1193 where: {
1194 remote: false,
1195 isLive: true
1196 },
1197 include: [
1198 {
1199 required: true,
1200 model: VideoChannelModel.unscoped(),
1201 where: {
1202 accountId
1203 }
1204 }
1205 ]
1206 }
1207
1208 return VideoModel.count(options)
1209 }
1210
1211 static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> {
1212 const where = buildWhereIdOrUUID(id)
1213 const options = {
1214 where,
1215 transaction: t
1216 }
1217
1218 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1219 }
1220
1221 static loadWithBlacklist (id: number | string, t?: Transaction): Bluebird<MVideoThumbnailBlacklist> {
1222 const where = buildWhereIdOrUUID(id)
1223 const options = {
1224 where,
1225 transaction: t
1226 }
1227
1228 return VideoModel.scope([
1229 ScopeNames.WITH_THUMBNAILS,
1230 ScopeNames.WITH_BLACKLISTED
1231 ]).findOne(options)
1232 }
1233
1234 static loadImmutableAttributes (id: number | string, t?: Transaction): Bluebird<MVideoImmutable> {
1235 const fun = () => {
1236 const query = {
1237 where: buildWhereIdOrUUID(id),
1238 transaction: t
1239 }
1240
1241 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1242 }
1243
1244 return ModelCache.Instance.doCache({
1245 cacheType: 'load-video-immutable-id',
1246 key: '' + id,
1247 deleteKey: 'video',
1248 fun
1249 })
1250 }
1251
1252 static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> {
1253 const where = buildWhereIdOrUUID(id)
1254 const options = {
1255 where,
1256 transaction: t
1257 }
1258
1259 return VideoModel.scope([
1260 ScopeNames.WITH_BLACKLISTED,
1261 ScopeNames.WITH_USER_ID,
1262 ScopeNames.WITH_THUMBNAILS
1263 ]).findOne(options)
1264 }
1265
1266 static loadOnlyId (id: number | string, t?: Transaction): Bluebird<MVideoIdThumbnail> {
1267 const where = buildWhereIdOrUUID(id)
1268
1269 const options = {
1270 attributes: [ 'id' ],
1271 where,
1272 transaction: t
1273 }
1274
1275 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1276 }
1277
1278 static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Bluebird<MVideoWithAllFiles> {
1279 const where = buildWhereIdOrUUID(id)
1280
1281 const query = {
1282 where,
1283 transaction: t,
1284 logging
1285 }
1286
1287 return VideoModel.scope([
1288 ScopeNames.WITH_WEBTORRENT_FILES,
1289 ScopeNames.WITH_STREAMING_PLAYLISTS,
1290 ScopeNames.WITH_THUMBNAILS
1291 ]).findOne(query)
1292 }
1293
1294 static loadByUUID (uuid: string): Bluebird<MVideoThumbnail> {
1295 const options = {
1296 where: {
1297 uuid
1298 }
1299 }
1300
1301 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1302 }
1303
1304 static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoThumbnail> {
1305 const query: FindOptions = {
1306 where: {
1307 url
1308 },
1309 transaction
1310 }
1311
1312 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1313 }
1314
1315 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Bluebird<MVideoImmutable> {
1316 const fun = () => {
1317 const query: FindOptions = {
1318 where: {
1319 url
1320 },
1321 transaction
1322 }
1323
1324 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1325 }
1326
1327 return ModelCache.Instance.doCache({
1328 cacheType: 'load-video-immutable-url',
1329 key: url,
1330 deleteKey: 'video',
1331 fun
1332 })
1333 }
1334
1335 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> {
1336 const query: FindOptions = {
1337 where: {
1338 url
1339 },
1340 transaction
1341 }
1342
1343 return VideoModel.scope([
1344 ScopeNames.WITH_ACCOUNT_DETAILS,
1345 ScopeNames.WITH_WEBTORRENT_FILES,
1346 ScopeNames.WITH_STREAMING_PLAYLISTS,
1347 ScopeNames.WITH_THUMBNAILS,
1348 ScopeNames.WITH_BLACKLISTED
1349 ]).findOne(query)
1350 }
1351
1352 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Bluebird<MVideoFullLight> {
1353 const where = buildWhereIdOrUUID(id)
1354
1355 const options = {
1356 order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1357 where,
1358 transaction: t
1359 }
1360
1361 const scopes: (string | ScopeOptions)[] = [
1362 ScopeNames.WITH_TAGS,
1363 ScopeNames.WITH_BLACKLISTED,
1364 ScopeNames.WITH_ACCOUNT_DETAILS,
1365 ScopeNames.WITH_SCHEDULED_UPDATE,
1366 ScopeNames.WITH_WEBTORRENT_FILES,
1367 ScopeNames.WITH_STREAMING_PLAYLISTS,
1368 ScopeNames.WITH_THUMBNAILS,
1369 ScopeNames.WITH_LIVE
1370 ]
1371
1372 if (userId) {
1373 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1374 }
1375
1376 return VideoModel
1377 .scope(scopes)
1378 .findOne(options)
1379 }
1380
1381 static loadForGetAPI (parameters: {
1382 id: number | string
1383 t?: Transaction
1384 userId?: number
1385 }): Bluebird<MVideoDetails> {
1386 const { id, t, userId } = parameters
1387 const where = buildWhereIdOrUUID(id)
1388
1389 const options = {
1390 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1391 where,
1392 transaction: t
1393 }
1394
1395 const scopes: (string | ScopeOptions)[] = [
1396 ScopeNames.WITH_TAGS,
1397 ScopeNames.WITH_BLACKLISTED,
1398 ScopeNames.WITH_ACCOUNT_DETAILS,
1399 ScopeNames.WITH_SCHEDULED_UPDATE,
1400 ScopeNames.WITH_THUMBNAILS,
1401 ScopeNames.WITH_LIVE,
1402 { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
1403 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1404 ]
1405
1406 if (userId) {
1407 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1408 }
1409
1410 return VideoModel
1411 .scope(scopes)
1412 .findOne(options)
1413 }
1414
1415 static async getStats () {
1416 const totalLocalVideos = await VideoModel.count({
1417 where: {
1418 remote: false
1419 }
1420 })
1421
1422 let totalLocalVideoViews = await VideoModel.sum('views', {
1423 where: {
1424 remote: false
1425 }
1426 })
1427
1428 // Sequelize could return null...
1429 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1430
1431 const { total: totalVideos } = await VideoModel.listForApi({
1432 start: 0,
1433 count: 0,
1434 sort: '-publishedAt',
1435 nsfw: buildNSFWFilter(),
1436 includeLocalVideos: true,
1437 withFiles: false
1438 })
1439
1440 return {
1441 totalLocalVideos,
1442 totalLocalVideoViews,
1443 totalVideos
1444 }
1445 }
1446
1447 static incrementViews (id: number, views: number) {
1448 return VideoModel.increment('views', {
1449 by: views,
1450 where: {
1451 id
1452 }
1453 })
1454 }
1455
1456 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1457 // Instances only share videos
1458 const query = 'SELECT 1 FROM "videoShare" ' +
1459 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1460 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1461 'LIMIT 1'
1462
1463 const options = {
1464 type: QueryTypes.SELECT as QueryTypes.SELECT,
1465 bind: { followerActorId, videoId },
1466 raw: true
1467 }
1468
1469 return VideoModel.sequelize.query(query, options)
1470 .then(results => results.length === 1)
1471 }
1472
1473 static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) {
1474 const options = {
1475 where: {
1476 channelId: videoChannel.id
1477 },
1478 transaction: t
1479 }
1480
1481 return VideoModel.update({ support: videoChannel.support }, options)
1482 }
1483
1484 static getAllIdsFromChannel (videoChannel: MChannelId): Bluebird<number[]> {
1485 const query = {
1486 attributes: [ 'id' ],
1487 where: {
1488 channelId: videoChannel.id
1489 }
1490 }
1491
1492 return VideoModel.findAll(query)
1493 .then(videos => videos.map(v => v.id))
1494 }
1495
1496 // threshold corresponds to how many video the field should have to be returned
1497 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1498 const serverActor = await getServerActor()
1499 const followerActorId = serverActor.id
1500
1501 const queryOptions: BuildVideosQueryOptions = {
1502 attributes: [ `"${field}"` ],
1503 group: `GROUP BY "${field}"`,
1504 having: `HAVING COUNT("${field}") >= ${threshold}`,
1505 start: 0,
1506 sort: 'random',
1507 count,
1508 serverAccountId: serverActor.Account.id,
1509 followerActorId,
1510 includeLocalVideos: true
1511 }
1512
1513 const { query, replacements } = buildListQuery(VideoModel, queryOptions)
1514
1515 return this.sequelize.query<any>(query, { replacements, type: QueryTypes.SELECT })
1516 .then(rows => rows.map(r => r[field]))
1517 }
1518
1519 static buildTrendingQuery (trendingDays: number) {
1520 return {
1521 attributes: [],
1522 subQuery: false,
1523 model: VideoViewModel,
1524 required: false,
1525 where: {
1526 startDate: {
1527 [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1528 }
1529 }
1530 }
1531 }
1532
1533 private static async getAvailableForApi (
1534 options: BuildVideosQueryOptions,
1535 countVideos = true
1536 ): Promise<ResultList<VideoModel>> {
1537 function getCount () {
1538 if (countVideos !== true) return Promise.resolve(undefined)
1539
1540 const countOptions = Object.assign({}, options, { isCount: true })
1541 const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel, countOptions)
1542
1543 return VideoModel.sequelize.query<any>(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT })
1544 .then(rows => rows.length !== 0 ? rows[0].total : 0)
1545 }
1546
1547 function getModels () {
1548 if (options.count === 0) return Promise.resolve([])
1549
1550 const { query, replacements, order } = buildListQuery(VideoModel, options)
1551 const queryModels = wrapForAPIResults(query, replacements, options, order)
1552
1553 return VideoModel.sequelize.query<any>(queryModels, { replacements, type: QueryTypes.SELECT, nest: true })
1554 .then(rows => VideoModel.buildAPIResult(rows))
1555 }
1556
1557 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1558
1559 return {
1560 data: rows,
1561 total: count
1562 }
1563 }
1564
1565 private static buildAPIResult (rows: any[]) {
1566 const videosMemo: { [ id: number ]: VideoModel } = {}
1567 const videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel } = {}
1568
1569 const thumbnailsDone = new Set<number>()
1570 const historyDone = new Set<number>()
1571 const videoFilesDone = new Set<number>()
1572
1573 const videos: VideoModel[] = []
1574
1575 const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ]
1576 const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ]
1577 const serverKeys = [ 'id', 'host' ]
1578 const videoFileKeys = [ 'id', 'createdAt', 'updatedAt', 'resolution', 'size', 'extname', 'infoHash', 'fps', 'videoId' ]
1579 const videoStreamingPlaylistKeys = [ 'id' ]
1580 const videoKeys = [
1581 'id',
1582 'uuid',
1583 'name',
1584 'category',
1585 'licence',
1586 'language',
1587 'privacy',
1588 'nsfw',
1589 'description',
1590 'support',
1591 'duration',
1592 'views',
1593 'likes',
1594 'dislikes',
1595 'remote',
1596 'url',
1597 'commentsEnabled',
1598 'downloadEnabled',
1599 'waitTranscoding',
1600 'state',
1601 'publishedAt',
1602 'originallyPublishedAt',
1603 'channelId',
1604 'createdAt',
1605 'updatedAt'
1606 ]
1607
1608 function buildActor (rowActor: any) {
1609 const avatarModel = rowActor.Avatar.id !== null
1610 ? new AvatarModel(pick(rowActor.Avatar, avatarKeys))
1611 : null
1612
1613 const serverModel = rowActor.Server.id !== null
1614 ? new ServerModel(pick(rowActor.Server, serverKeys))
1615 : null
1616
1617 const actorModel = new ActorModel(pick(rowActor, actorKeys))
1618 actorModel.Avatar = avatarModel
1619 actorModel.Server = serverModel
1620
1621 return actorModel
1622 }
1623
1624 for (const row of rows) {
1625 if (!videosMemo[row.id]) {
1626 // Build Channel
1627 const channel = row.VideoChannel
1628 const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ]))
1629 channelModel.Actor = buildActor(channel.Actor)
1630
1631 const account = row.VideoChannel.Account
1632 const accountModel = new AccountModel(pick(account, [ 'id', 'name' ]))
1633 accountModel.Actor = buildActor(account.Actor)
1634
1635 channelModel.Account = accountModel
1636
1637 const videoModel = new VideoModel(pick(row, videoKeys))
1638 videoModel.VideoChannel = channelModel
1639
1640 videoModel.UserVideoHistories = []
1641 videoModel.Thumbnails = []
1642 videoModel.VideoFiles = []
1643 videoModel.VideoStreamingPlaylists = []
1644
1645 videosMemo[row.id] = videoModel
1646 // Don't take object value to have a sorted array
1647 videos.push(videoModel)
1648 }
1649
1650 const videoModel = videosMemo[row.id]
1651
1652 if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) {
1653 const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ]))
1654 videoModel.UserVideoHistories.push(historyModel)
1655
1656 historyDone.add(row.userVideoHistory.id)
1657 }
1658
1659 if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) {
1660 const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ]))
1661 videoModel.Thumbnails.push(thumbnailModel)
1662
1663 thumbnailsDone.add(row.Thumbnails.id)
1664 }
1665
1666 if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) {
1667 const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys))
1668 videoModel.VideoFiles.push(videoFileModel)
1669
1670 videoFilesDone.add(row.VideoFiles.id)
1671 }
1672
1673 if (row.VideoStreamingPlaylists?.id && !videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]) {
1674 const streamingPlaylist = new VideoStreamingPlaylistModel(pick(row.VideoStreamingPlaylists, videoStreamingPlaylistKeys))
1675 streamingPlaylist.VideoFiles = []
1676
1677 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
1678
1679 videoStreamingPlaylistMemo[streamingPlaylist.id] = streamingPlaylist
1680 }
1681
1682 if (row.VideoStreamingPlaylists?.VideoFiles?.id && !videoFilesDone.has(row.VideoStreamingPlaylists.VideoFiles.id)) {
1683 const streamingPlaylist = videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]
1684
1685 const videoFileModel = new VideoFileModel(pick(row.VideoStreamingPlaylists.VideoFiles, videoFileKeys))
1686 streamingPlaylist.VideoFiles.push(videoFileModel)
1687
1688 videoFilesDone.add(row.VideoStreamingPlaylists.VideoFiles.id)
1689 }
1690 }
1691
1692 return videos
1693 }
1694
1695 static getCategoryLabel (id: number) {
1696 return VIDEO_CATEGORIES[id] || 'Misc'
1697 }
1698
1699 static getLicenceLabel (id: number) {
1700 return VIDEO_LICENCES[id] || 'Unknown'
1701 }
1702
1703 static getLanguageLabel (id: string) {
1704 return VIDEO_LANGUAGES[id] || 'Unknown'
1705 }
1706
1707 static getPrivacyLabel (id: number) {
1708 return VIDEO_PRIVACIES[id] || 'Unknown'
1709 }
1710
1711 static getStateLabel (id: number) {
1712 return VIDEO_STATES[id] || 'Unknown'
1713 }
1714
1715 isBlacklisted () {
1716 return !!this.VideoBlacklist
1717 }
1718
1719 isBlocked () {
1720 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1721 }
1722
1723 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1724 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1725 const file = fun(this.VideoFiles, file => file.resolution)
1726
1727 return Object.assign(file, { Video: this })
1728 }
1729
1730 // No webtorrent files, try with streaming playlist files
1731 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1732 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1733
1734 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1735 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1736 }
1737
1738 return undefined
1739 }
1740
1741 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1742 return this.getQualityFileBy(maxBy)
1743 }
1744
1745 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1746 return this.getQualityFileBy(minBy)
1747 }
1748
1749 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1750 if (Array.isArray(this.VideoFiles) === false) return undefined
1751
1752 const file = this.VideoFiles.find(f => f.resolution === resolution)
1753 if (!file) return undefined
1754
1755 return Object.assign(file, { Video: this })
1756 }
1757
1758 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) {
1759 thumbnail.videoId = this.id
1760
1761 const savedThumbnail = await thumbnail.save({ transaction })
1762
1763 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1764
1765 // Already have this thumbnail, skip
1766 if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1767
1768 this.Thumbnails.push(savedThumbnail)
1769 }
1770
1771 generateThumbnailName () {
1772 return this.uuid + '.jpg'
1773 }
1774
1775 getMiniature () {
1776 if (Array.isArray(this.Thumbnails) === false) return undefined
1777
1778 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1779 }
1780
1781 generatePreviewName () {
1782 return this.uuid + '.jpg'
1783 }
1784
1785 hasPreview () {
1786 return !!this.getPreview()
1787 }
1788
1789 getPreview () {
1790 if (Array.isArray(this.Thumbnails) === false) return undefined
1791
1792 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1793 }
1794
1795 isOwned () {
1796 return this.remote === false
1797 }
1798
1799 getWatchStaticPath () {
1800 return '/videos/watch/' + this.uuid
1801 }
1802
1803 getEmbedStaticPath () {
1804 return '/videos/embed/' + this.uuid
1805 }
1806
1807 getMiniatureStaticPath () {
1808 const thumbnail = this.getMiniature()
1809 if (!thumbnail) return null
1810
1811 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1812 }
1813
1814 getPreviewStaticPath () {
1815 const preview = this.getPreview()
1816 if (!preview) return null
1817
1818 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1819 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1820 }
1821
1822 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1823 return videoModelToFormattedJSON(this, options)
1824 }
1825
1826 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1827 return videoModelToFormattedDetailsJSON(this)
1828 }
1829
1830 getFormattedVideoFilesJSON (): VideoFile[] {
1831 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1832 let files: MVideoFileRedundanciesOpt[] = []
1833
1834 if (Array.isArray(this.VideoFiles)) {
1835 files = files.concat(this.VideoFiles)
1836 }
1837
1838 for (const p of (this.VideoStreamingPlaylists || [])) {
1839 files = files.concat(p.VideoFiles || [])
1840 }
1841
1842 return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, files)
1843 }
1844
1845 toActivityPubObject (this: MVideoAP): VideoObject {
1846 return videoModelToActivityPubObject(this)
1847 }
1848
1849 getTruncatedDescription () {
1850 if (!this.description) return null
1851
1852 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1853 return peertubeTruncate(this.description, { length: maxLength })
1854 }
1855
1856 getMaxQualityResolution () {
1857 const file = this.getMaxQualityFile()
1858 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1859 const originalFilePath = getVideoFilePath(videoOrPlaylist, file)
1860
1861 return getVideoFileResolution(originalFilePath)
1862 }
1863
1864 getDescriptionAPIPath () {
1865 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1866 }
1867
1868 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1869 if (!this.VideoStreamingPlaylists) return undefined
1870
1871 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1872 playlist.Video = this
1873
1874 return playlist
1875 }
1876
1877 setHLSPlaylist (playlist: MStreamingPlaylist) {
1878 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1879
1880 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1881 this.VideoStreamingPlaylists = toAdd
1882 return
1883 }
1884
1885 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1886 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1887 .concat(toAdd)
1888 }
1889
1890 removeFile (videoFile: MVideoFile, isRedundancy = false) {
1891 const filePath = getVideoFilePath(this, videoFile, isRedundancy)
1892 return remove(filePath)
1893 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1894 }
1895
1896 removeTorrent (videoFile: MVideoFile) {
1897 const torrentPath = getTorrentFilePath(this, videoFile)
1898 return remove(torrentPath)
1899 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1900 }
1901
1902 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1903 const directoryPath = getHLSDirectory(this, isRedundancy)
1904
1905 await remove(directoryPath)
1906
1907 if (isRedundancy !== true) {
1908 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1909 streamingPlaylistWithFiles.Video = this
1910
1911 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1912 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1913 }
1914
1915 // Remove physical files and torrents
1916 await Promise.all(
1917 streamingPlaylistWithFiles.VideoFiles.map(file => streamingPlaylistWithFiles.removeTorrent(file))
1918 )
1919 }
1920 }
1921
1922 isOutdated () {
1923 if (this.isOwned()) return false
1924
1925 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1926 }
1927
1928 hasPrivacyForFederation () {
1929 return isPrivacyForFederation(this.privacy)
1930 }
1931
1932 hasStateForFederation () {
1933 return isStateForFederation(this.state)
1934 }
1935
1936 isNewVideo (newPrivacy: VideoPrivacy) {
1937 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1938 }
1939
1940 setAsRefreshed () {
1941 this.changed('updatedAt', true)
1942
1943 return this.save()
1944 }
1945
1946 requiresAuth () {
1947 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
1948 }
1949
1950 setPrivacy (newPrivacy: VideoPrivacy) {
1951 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
1952 this.publishedAt = new Date()
1953 }
1954
1955 this.privacy = newPrivacy
1956 }
1957
1958 isConfidential () {
1959 return this.privacy === VideoPrivacy.PRIVATE ||
1960 this.privacy === VideoPrivacy.UNLISTED ||
1961 this.privacy === VideoPrivacy.INTERNAL
1962 }
1963
1964 async publishIfNeededAndSave (t: Transaction) {
1965 if (this.state !== VideoState.PUBLISHED) {
1966 this.state = VideoState.PUBLISHED
1967 this.publishedAt = new Date()
1968 await this.save({ transaction: t })
1969
1970 return true
1971 }
1972
1973 return false
1974 }
1975
1976 getBaseUrls () {
1977 if (this.isOwned()) {
1978 return {
1979 baseUrlHttp: WEBSERVER.URL,
1980 baseUrlWs: WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
1981 }
1982 }
1983
1984 return {
1985 baseUrlHttp: REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host,
1986 baseUrlWs: REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1987 }
1988 }
1989
1990 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1991 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1992 }
1993
1994 getTorrentUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1995 return baseUrlHttp + STATIC_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
1996 }
1997
1998 getTorrentDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1999 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
2000 }
2001
2002 getVideoFileUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2003 return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile)
2004 }
2005
2006 getVideoFileMetadataUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2007 const path = '/api/v1/videos/'
2008
2009 return this.isOwned()
2010 ? baseUrlHttp + path + this.uuid + '/metadata/' + videoFile.id
2011 : videoFile.metadataUrl
2012 }
2013
2014 getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2015 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile)
2016 }
2017
2018 getVideoFileDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2019 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + getVideoFilename(this, videoFile)
2020 }
2021
2022 getBandwidthBits (videoFile: MVideoFile) {
2023 return Math.ceil((videoFile.size * 8) / this.duration)
2024 }
2025 }