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