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