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