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