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