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