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