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