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