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