]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
749ef7197105bcb059bae197a8d9635e5ae6b00c
[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 state: VideoState.PUBLISHED
1013 }
1014 }
1015
1016 const result = await VideoModel.findAll(options)
1017
1018 return result.map(v => v.id)
1019 }
1020
1021 static listUserVideosForApi (options: {
1022 accountId: number
1023 start: number
1024 count: number
1025 sort: string
1026 isLive?: boolean
1027 search?: string
1028 }) {
1029 const { accountId, start, count, sort, search, isLive } = options
1030
1031 function buildBaseQuery (): FindOptions {
1032 const where: WhereOptions = {}
1033
1034 if (search) {
1035 where.name = {
1036 [Op.iLike]: '%' + search + '%'
1037 }
1038 }
1039
1040 if (isLive) {
1041 where.isLive = isLive
1042 }
1043
1044 const baseQuery = {
1045 offset: start,
1046 limit: count,
1047 where,
1048 order: getVideoSort(sort),
1049 include: [
1050 {
1051 model: VideoChannelModel,
1052 required: true,
1053 include: [
1054 {
1055 model: AccountModel,
1056 where: {
1057 id: accountId
1058 },
1059 required: true
1060 }
1061 ]
1062 }
1063 ]
1064 }
1065
1066 return baseQuery
1067 }
1068
1069 const countQuery = buildBaseQuery()
1070 const findQuery = buildBaseQuery()
1071
1072 const findScopes: (string | ScopeOptions)[] = [
1073 ScopeNames.WITH_SCHEDULED_UPDATE,
1074 ScopeNames.WITH_BLACKLISTED,
1075 ScopeNames.WITH_THUMBNAILS
1076 ]
1077
1078 return Promise.all([
1079 VideoModel.count(countQuery),
1080 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1081 ]).then(([ count, rows ]) => {
1082 return {
1083 data: rows,
1084 total: count
1085 }
1086 })
1087 }
1088
1089 static async listForApi (options: {
1090 start: number
1091 count: number
1092 sort: string
1093
1094 nsfw: boolean
1095 filter?: VideoFilter
1096 isLive?: boolean
1097
1098 includeLocalVideos: boolean
1099 withFiles: boolean
1100
1101 categoryOneOf?: number[]
1102 licenceOneOf?: number[]
1103 languageOneOf?: string[]
1104 tagsOneOf?: string[]
1105 tagsAllOf?: string[]
1106
1107 accountId?: number
1108 videoChannelId?: number
1109
1110 followerActorId?: number
1111
1112 videoPlaylistId?: number
1113
1114 trendingDays?: number
1115
1116 user?: MUserAccountId
1117 historyOfUser?: MUserId
1118
1119 countVideos?: boolean
1120
1121 search?: string
1122 }) {
1123 if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1124 throw new Error('Try to filter all-local but no user has not the see all videos right')
1125 }
1126
1127 const trendingDays = options.sort.endsWith('trending')
1128 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1129 : undefined
1130 let trendingAlgorithm
1131 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1132 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1133
1134 const serverActor = await getServerActor()
1135
1136 // followerActorId === null has a meaning, so just check undefined
1137 const followerActorId = options.followerActorId !== undefined
1138 ? options.followerActorId
1139 : serverActor.id
1140
1141 const queryOptions = {
1142 start: options.start,
1143 count: options.count,
1144 sort: options.sort,
1145 followerActorId,
1146 serverAccountId: serverActor.Account.id,
1147 nsfw: options.nsfw,
1148 isLive: options.isLive,
1149 categoryOneOf: options.categoryOneOf,
1150 licenceOneOf: options.licenceOneOf,
1151 languageOneOf: options.languageOneOf,
1152 tagsOneOf: options.tagsOneOf,
1153 tagsAllOf: options.tagsAllOf,
1154 filter: options.filter,
1155 withFiles: options.withFiles,
1156 accountId: options.accountId,
1157 videoChannelId: options.videoChannelId,
1158 videoPlaylistId: options.videoPlaylistId,
1159 includeLocalVideos: options.includeLocalVideos,
1160 user: options.user,
1161 historyOfUser: options.historyOfUser,
1162 trendingDays,
1163 trendingAlgorithm,
1164 search: options.search
1165 }
1166
1167 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1168 }
1169
1170 static async searchAndPopulateAccountAndServer (options: {
1171 includeLocalVideos: boolean
1172 search?: string
1173 start?: number
1174 count?: number
1175 sort?: string
1176 startDate?: string // ISO 8601
1177 endDate?: string // ISO 8601
1178 originallyPublishedStartDate?: string
1179 originallyPublishedEndDate?: string
1180 nsfw?: boolean
1181 isLive?: boolean
1182 categoryOneOf?: number[]
1183 licenceOneOf?: number[]
1184 languageOneOf?: string[]
1185 tagsOneOf?: string[]
1186 tagsAllOf?: string[]
1187 durationMin?: number // seconds
1188 durationMax?: number // seconds
1189 user?: MUserAccountId
1190 filter?: VideoFilter
1191 }) {
1192 const serverActor = await getServerActor()
1193
1194 const queryOptions = {
1195 followerActorId: serverActor.id,
1196 serverAccountId: serverActor.Account.id,
1197
1198 includeLocalVideos: options.includeLocalVideos,
1199 nsfw: options.nsfw,
1200 isLive: options.isLive,
1201
1202 categoryOneOf: options.categoryOneOf,
1203 licenceOneOf: options.licenceOneOf,
1204 languageOneOf: options.languageOneOf,
1205
1206 tagsOneOf: options.tagsOneOf,
1207 tagsAllOf: options.tagsAllOf,
1208
1209 user: options.user,
1210 filter: options.filter,
1211
1212 start: options.start,
1213 count: options.count,
1214 sort: options.sort,
1215
1216 startDate: options.startDate,
1217 endDate: options.endDate,
1218
1219 originallyPublishedStartDate: options.originallyPublishedStartDate,
1220 originallyPublishedEndDate: options.originallyPublishedEndDate,
1221
1222 durationMin: options.durationMin,
1223 durationMax: options.durationMax,
1224
1225 search: options.search
1226 }
1227
1228 return VideoModel.getAvailableForApi(queryOptions)
1229 }
1230
1231 static countLocalLives () {
1232 const options = {
1233 where: {
1234 remote: false,
1235 isLive: true,
1236 state: {
1237 [Op.ne]: VideoState.LIVE_ENDED
1238 }
1239 }
1240 }
1241
1242 return VideoModel.count(options)
1243 }
1244
1245 static countVideosUploadedByUserSince (userId: number, since: Date) {
1246 const options = {
1247 include: [
1248 {
1249 model: VideoChannelModel.unscoped(),
1250 required: true,
1251 include: [
1252 {
1253 model: AccountModel.unscoped(),
1254 required: true,
1255 include: [
1256 {
1257 model: UserModel.unscoped(),
1258 required: true,
1259 where: {
1260 id: userId
1261 }
1262 }
1263 ]
1264 }
1265 ]
1266 }
1267 ],
1268 where: {
1269 createdAt: {
1270 [Op.gte]: since
1271 }
1272 }
1273 }
1274
1275 return VideoModel.unscoped().count(options)
1276 }
1277
1278 static countLivesOfAccount (accountId: number) {
1279 const options = {
1280 where: {
1281 remote: false,
1282 isLive: true,
1283 state: {
1284 [Op.ne]: VideoState.LIVE_ENDED
1285 }
1286 },
1287 include: [
1288 {
1289 required: true,
1290 model: VideoChannelModel.unscoped(),
1291 where: {
1292 accountId
1293 }
1294 }
1295 ]
1296 }
1297
1298 return VideoModel.count(options)
1299 }
1300
1301 static load (id: number | string, t?: Transaction): Promise<MVideoThumbnail> {
1302 const where = buildWhereIdOrUUID(id)
1303 const options = {
1304 where,
1305 transaction: t
1306 }
1307
1308 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1309 }
1310
1311 static loadWithBlacklist (id: number | string, t?: Transaction): Promise<MVideoThumbnailBlacklist> {
1312 const where = buildWhereIdOrUUID(id)
1313 const options = {
1314 where,
1315 transaction: t
1316 }
1317
1318 return VideoModel.scope([
1319 ScopeNames.WITH_THUMBNAILS,
1320 ScopeNames.WITH_BLACKLISTED
1321 ]).findOne(options)
1322 }
1323
1324 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1325 const fun = () => {
1326 const query = {
1327 where: buildWhereIdOrUUID(id),
1328 transaction: t
1329 }
1330
1331 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1332 }
1333
1334 return ModelCache.Instance.doCache({
1335 cacheType: 'load-video-immutable-id',
1336 key: '' + id,
1337 deleteKey: 'video',
1338 fun
1339 })
1340 }
1341
1342 static loadWithRights (id: number | string, t?: Transaction): Promise<MVideoWithRights> {
1343 const where = buildWhereIdOrUUID(id)
1344 const options = {
1345 where,
1346 transaction: t
1347 }
1348
1349 return VideoModel.scope([
1350 ScopeNames.WITH_BLACKLISTED,
1351 ScopeNames.WITH_USER_ID
1352 ]).findOne(options)
1353 }
1354
1355 static loadOnlyId (id: number | string, t?: Transaction): Promise<MVideoIdThumbnail> {
1356 const where = buildWhereIdOrUUID(id)
1357
1358 const options = {
1359 attributes: [ 'id' ],
1360 where,
1361 transaction: t
1362 }
1363
1364 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1365 }
1366
1367 static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1368 const where = buildWhereIdOrUUID(id)
1369
1370 const query = {
1371 where,
1372 transaction: t,
1373 logging
1374 }
1375
1376 return VideoModel.scope([
1377 ScopeNames.WITH_WEBTORRENT_FILES,
1378 ScopeNames.WITH_STREAMING_PLAYLISTS,
1379 ScopeNames.WITH_THUMBNAILS
1380 ]).findOne(query)
1381 }
1382
1383 static loadByUUID (uuid: string): Promise<MVideoThumbnail> {
1384 const options = {
1385 where: {
1386 uuid
1387 }
1388 }
1389
1390 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1391 }
1392
1393 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1394 const query: FindOptions = {
1395 where: {
1396 url
1397 },
1398 transaction
1399 }
1400
1401 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1402 }
1403
1404 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1405 const fun = () => {
1406 const query: FindOptions = {
1407 where: {
1408 url
1409 },
1410 transaction
1411 }
1412
1413 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1414 }
1415
1416 return ModelCache.Instance.doCache({
1417 cacheType: 'load-video-immutable-url',
1418 key: url,
1419 deleteKey: 'video',
1420 fun
1421 })
1422 }
1423
1424 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1425 const query: FindOptions = {
1426 where: {
1427 url
1428 },
1429 transaction
1430 }
1431
1432 return VideoModel.scope([
1433 ScopeNames.WITH_ACCOUNT_DETAILS,
1434 ScopeNames.WITH_WEBTORRENT_FILES,
1435 ScopeNames.WITH_STREAMING_PLAYLISTS,
1436 ScopeNames.WITH_THUMBNAILS,
1437 ScopeNames.WITH_BLACKLISTED
1438 ]).findOne(query)
1439 }
1440
1441 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1442 const where = buildWhereIdOrUUID(id)
1443
1444 const options = {
1445 order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1446 where,
1447 transaction: t
1448 }
1449
1450 const scopes: (string | ScopeOptions)[] = [
1451 ScopeNames.WITH_TAGS,
1452 ScopeNames.WITH_BLACKLISTED,
1453 ScopeNames.WITH_ACCOUNT_DETAILS,
1454 ScopeNames.WITH_SCHEDULED_UPDATE,
1455 ScopeNames.WITH_WEBTORRENT_FILES,
1456 ScopeNames.WITH_STREAMING_PLAYLISTS,
1457 ScopeNames.WITH_THUMBNAILS,
1458 ScopeNames.WITH_LIVE
1459 ]
1460
1461 if (userId) {
1462 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1463 }
1464
1465 return VideoModel
1466 .scope(scopes)
1467 .findOne(options)
1468 }
1469
1470 static loadForGetAPI (parameters: {
1471 id: number | string
1472 t?: Transaction
1473 userId?: number
1474 }): Promise<MVideoDetails> {
1475 const { id, t, userId } = parameters
1476 const where = buildWhereIdOrUUID(id)
1477
1478 const options = {
1479 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1480 where,
1481 transaction: t
1482 }
1483
1484 const scopes: (string | ScopeOptions)[] = [
1485 ScopeNames.WITH_TAGS,
1486 ScopeNames.WITH_BLACKLISTED,
1487 ScopeNames.WITH_ACCOUNT_DETAILS,
1488 ScopeNames.WITH_SCHEDULED_UPDATE,
1489 ScopeNames.WITH_THUMBNAILS,
1490 ScopeNames.WITH_LIVE,
1491 ScopeNames.WITH_TRACKERS,
1492 { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
1493 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1494 ]
1495
1496 if (userId) {
1497 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1498 }
1499
1500 return VideoModel
1501 .scope(scopes)
1502 .findOne(options)
1503 }
1504
1505 static async getStats () {
1506 const totalLocalVideos = await VideoModel.count({
1507 where: {
1508 remote: false
1509 }
1510 })
1511
1512 let totalLocalVideoViews = await VideoModel.sum('views', {
1513 where: {
1514 remote: false
1515 }
1516 })
1517
1518 // Sequelize could return null...
1519 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1520
1521 const { total: totalVideos } = await VideoModel.listForApi({
1522 start: 0,
1523 count: 0,
1524 sort: '-publishedAt',
1525 nsfw: buildNSFWFilter(),
1526 includeLocalVideos: true,
1527 withFiles: false
1528 })
1529
1530 return {
1531 totalLocalVideos,
1532 totalLocalVideoViews,
1533 totalVideos
1534 }
1535 }
1536
1537 static incrementViews (id: number, views: number) {
1538 return VideoModel.increment('views', {
1539 by: views,
1540 where: {
1541 id
1542 }
1543 })
1544 }
1545
1546 static updateRatesOf (videoId: number, type: VideoRateType, t: Transaction) {
1547 const field = type === 'like'
1548 ? 'likes'
1549 : 'dislikes'
1550
1551 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1552 '(' +
1553 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1554 ') ' +
1555 'WHERE "video"."id" = :videoId'
1556
1557 return AccountVideoRateModel.sequelize.query(rawQuery, {
1558 transaction: t,
1559 replacements: { videoId, rateType: type },
1560 type: QueryTypes.UPDATE
1561 })
1562 }
1563
1564 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1565 // Instances only share videos
1566 const query = 'SELECT 1 FROM "videoShare" ' +
1567 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1568 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1569 'LIMIT 1'
1570
1571 const options = {
1572 type: QueryTypes.SELECT as QueryTypes.SELECT,
1573 bind: { followerActorId, videoId },
1574 raw: true
1575 }
1576
1577 return VideoModel.sequelize.query(query, options)
1578 .then(results => results.length === 1)
1579 }
1580
1581 static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) {
1582 const options = {
1583 where: {
1584 channelId: videoChannel.id
1585 },
1586 transaction: t
1587 }
1588
1589 return VideoModel.update({ support: videoChannel.support }, options)
1590 }
1591
1592 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1593 const query = {
1594 attributes: [ 'id' ],
1595 where: {
1596 channelId: videoChannel.id
1597 }
1598 }
1599
1600 return VideoModel.findAll(query)
1601 .then(videos => videos.map(v => v.id))
1602 }
1603
1604 // threshold corresponds to how many video the field should have to be returned
1605 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1606 const serverActor = await getServerActor()
1607 const followerActorId = serverActor.id
1608
1609 const queryOptions: BuildVideosQueryOptions = {
1610 attributes: [ `"${field}"` ],
1611 group: `GROUP BY "${field}"`,
1612 having: `HAVING COUNT("${field}") >= ${threshold}`,
1613 start: 0,
1614 sort: 'random',
1615 count,
1616 serverAccountId: serverActor.Account.id,
1617 followerActorId,
1618 includeLocalVideos: true
1619 }
1620
1621 const { query, replacements } = buildListQuery(VideoModel.sequelize, queryOptions)
1622
1623 return this.sequelize.query<any>(query, { replacements, type: QueryTypes.SELECT })
1624 .then(rows => rows.map(r => r[field]))
1625 }
1626
1627 static buildTrendingQuery (trendingDays: number) {
1628 return {
1629 attributes: [],
1630 subQuery: false,
1631 model: VideoViewModel,
1632 required: false,
1633 where: {
1634 startDate: {
1635 [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1636 }
1637 }
1638 }
1639 }
1640
1641 private static async getAvailableForApi (
1642 options: BuildVideosQueryOptions,
1643 countVideos = true
1644 ): Promise<ResultList<VideoModel>> {
1645 function getCount () {
1646 if (countVideos !== true) return Promise.resolve(undefined)
1647
1648 const countOptions = Object.assign({}, options, { isCount: true })
1649 const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel.sequelize, countOptions)
1650
1651 return VideoModel.sequelize.query<any>(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT })
1652 .then(rows => rows.length !== 0 ? rows[0].total : 0)
1653 }
1654
1655 function getModels () {
1656 if (options.count === 0) return Promise.resolve([])
1657
1658 const { query, replacements, order } = buildListQuery(VideoModel.sequelize, options)
1659 const queryModels = wrapForAPIResults(query, replacements, options, order)
1660
1661 return VideoModel.sequelize.query<any>(queryModels, { replacements, type: QueryTypes.SELECT, nest: true })
1662 .then(rows => VideoModel.buildAPIResult(rows))
1663 }
1664
1665 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1666
1667 return {
1668 data: rows,
1669 total: count
1670 }
1671 }
1672
1673 private static buildAPIResult (rows: any[]) {
1674 const videosMemo: { [ id: number ]: VideoModel } = {}
1675 const videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel } = {}
1676
1677 const thumbnailsDone = new Set<number>()
1678 const historyDone = new Set<number>()
1679 const videoFilesDone = new Set<number>()
1680
1681 const videos: VideoModel[] = []
1682
1683 const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ]
1684 const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ]
1685 const serverKeys = [ 'id', 'host' ]
1686 const videoFileKeys = [
1687 'id',
1688 'createdAt',
1689 'updatedAt',
1690 'resolution',
1691 'size',
1692 'extname',
1693 'filename',
1694 'fileUrl',
1695 'torrentFilename',
1696 'torrentUrl',
1697 'infoHash',
1698 'fps',
1699 'videoId',
1700 'videoStreamingPlaylistId'
1701 ]
1702 const videoStreamingPlaylistKeys = [ 'id', 'type', 'playlistUrl' ]
1703 const videoKeys = [
1704 'id',
1705 'uuid',
1706 'name',
1707 'category',
1708 'licence',
1709 'language',
1710 'privacy',
1711 'nsfw',
1712 'description',
1713 'support',
1714 'duration',
1715 'views',
1716 'likes',
1717 'dislikes',
1718 'remote',
1719 'isLive',
1720 'url',
1721 'commentsEnabled',
1722 'downloadEnabled',
1723 'waitTranscoding',
1724 'state',
1725 'publishedAt',
1726 'originallyPublishedAt',
1727 'channelId',
1728 'createdAt',
1729 'updatedAt'
1730 ]
1731 const buildOpts = { raw: true }
1732
1733 function buildActor (rowActor: any) {
1734 const avatarModel = rowActor.Avatar.id !== null
1735 ? new ActorImageModel(pick(rowActor.Avatar, avatarKeys), buildOpts)
1736 : null
1737
1738 const serverModel = rowActor.Server.id !== null
1739 ? new ServerModel(pick(rowActor.Server, serverKeys), buildOpts)
1740 : null
1741
1742 const actorModel = new ActorModel(pick(rowActor, actorKeys), buildOpts)
1743 actorModel.Avatar = avatarModel
1744 actorModel.Server = serverModel
1745
1746 return actorModel
1747 }
1748
1749 for (const row of rows) {
1750 if (!videosMemo[row.id]) {
1751 // Build Channel
1752 const channel = row.VideoChannel
1753 const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ]), buildOpts)
1754 channelModel.Actor = buildActor(channel.Actor)
1755
1756 const account = row.VideoChannel.Account
1757 const accountModel = new AccountModel(pick(account, [ 'id', 'name' ]), buildOpts)
1758 accountModel.Actor = buildActor(account.Actor)
1759
1760 channelModel.Account = accountModel
1761
1762 const videoModel = new VideoModel(pick(row, videoKeys), buildOpts)
1763 videoModel.VideoChannel = channelModel
1764
1765 videoModel.UserVideoHistories = []
1766 videoModel.Thumbnails = []
1767 videoModel.VideoFiles = []
1768 videoModel.VideoStreamingPlaylists = []
1769
1770 videosMemo[row.id] = videoModel
1771 // Don't take object value to have a sorted array
1772 videos.push(videoModel)
1773 }
1774
1775 const videoModel = videosMemo[row.id]
1776
1777 if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) {
1778 const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ]), buildOpts)
1779 videoModel.UserVideoHistories.push(historyModel)
1780
1781 historyDone.add(row.userVideoHistory.id)
1782 }
1783
1784 if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) {
1785 const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ]), buildOpts)
1786 videoModel.Thumbnails.push(thumbnailModel)
1787
1788 thumbnailsDone.add(row.Thumbnails.id)
1789 }
1790
1791 if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) {
1792 const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys), buildOpts)
1793 videoModel.VideoFiles.push(videoFileModel)
1794
1795 videoFilesDone.add(row.VideoFiles.id)
1796 }
1797
1798 if (row.VideoStreamingPlaylists?.id && !videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]) {
1799 const streamingPlaylist = new VideoStreamingPlaylistModel(pick(row.VideoStreamingPlaylists, videoStreamingPlaylistKeys), buildOpts)
1800 streamingPlaylist.VideoFiles = []
1801
1802 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
1803
1804 videoStreamingPlaylistMemo[streamingPlaylist.id] = streamingPlaylist
1805 }
1806
1807 if (row.VideoStreamingPlaylists?.VideoFiles?.id && !videoFilesDone.has(row.VideoStreamingPlaylists.VideoFiles.id)) {
1808 const streamingPlaylist = videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]
1809
1810 const videoFileModel = new VideoFileModel(pick(row.VideoStreamingPlaylists.VideoFiles, videoFileKeys), buildOpts)
1811 streamingPlaylist.VideoFiles.push(videoFileModel)
1812
1813 videoFilesDone.add(row.VideoStreamingPlaylists.VideoFiles.id)
1814 }
1815 }
1816
1817 return videos
1818 }
1819
1820 static getCategoryLabel (id: number) {
1821 return VIDEO_CATEGORIES[id] || 'Misc'
1822 }
1823
1824 static getLicenceLabel (id: number) {
1825 return VIDEO_LICENCES[id] || 'Unknown'
1826 }
1827
1828 static getLanguageLabel (id: string) {
1829 return VIDEO_LANGUAGES[id] || 'Unknown'
1830 }
1831
1832 static getPrivacyLabel (id: number) {
1833 return VIDEO_PRIVACIES[id] || 'Unknown'
1834 }
1835
1836 static getStateLabel (id: number) {
1837 return VIDEO_STATES[id] || 'Unknown'
1838 }
1839
1840 isBlacklisted () {
1841 return !!this.VideoBlacklist
1842 }
1843
1844 isBlocked () {
1845 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1846 }
1847
1848 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1849 // We first transcode to WebTorrent format, so try this array first
1850 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1851 const file = fun(this.VideoFiles, file => file.resolution)
1852
1853 return Object.assign(file, { Video: this })
1854 }
1855
1856 // No webtorrent files, try with streaming playlist files
1857 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1858 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1859
1860 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1861 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1862 }
1863
1864 return undefined
1865 }
1866
1867 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1868 return this.getQualityFileBy(maxBy)
1869 }
1870
1871 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1872 return this.getQualityFileBy(minBy)
1873 }
1874
1875 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1876 if (Array.isArray(this.VideoFiles) === false) return undefined
1877
1878 const file = this.VideoFiles.find(f => f.resolution === resolution)
1879 if (!file) return undefined
1880
1881 return Object.assign(file, { Video: this })
1882 }
1883
1884 hasWebTorrentFiles () {
1885 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1886 }
1887
1888 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) {
1889 thumbnail.videoId = this.id
1890
1891 const savedThumbnail = await thumbnail.save({ transaction })
1892
1893 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1894
1895 // Already have this thumbnail, skip
1896 if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1897
1898 this.Thumbnails.push(savedThumbnail)
1899 }
1900
1901 getMiniature () {
1902 if (Array.isArray(this.Thumbnails) === false) return undefined
1903
1904 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1905 }
1906
1907 hasPreview () {
1908 return !!this.getPreview()
1909 }
1910
1911 getPreview () {
1912 if (Array.isArray(this.Thumbnails) === false) return undefined
1913
1914 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1915 }
1916
1917 isOwned () {
1918 return this.remote === false
1919 }
1920
1921 getWatchStaticPath () {
1922 return '/videos/watch/' + this.uuid
1923 }
1924
1925 getEmbedStaticPath () {
1926 return '/videos/embed/' + this.uuid
1927 }
1928
1929 getMiniatureStaticPath () {
1930 const thumbnail = this.getMiniature()
1931 if (!thumbnail) return null
1932
1933 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1934 }
1935
1936 getPreviewStaticPath () {
1937 const preview = this.getPreview()
1938 if (!preview) return null
1939
1940 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1941 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1942 }
1943
1944 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1945 return videoModelToFormattedJSON(this, options)
1946 }
1947
1948 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1949 return videoModelToFormattedDetailsJSON(this)
1950 }
1951
1952 getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
1953 let files: VideoFile[] = []
1954
1955 if (Array.isArray(this.VideoFiles)) {
1956 const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet)
1957 files = files.concat(result)
1958 }
1959
1960 for (const p of (this.VideoStreamingPlaylists || [])) {
1961 const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet)
1962 files = files.concat(result)
1963 }
1964
1965 return files
1966 }
1967
1968 toActivityPubObject (this: MVideoAP): VideoObject {
1969 return videoModelToActivityPubObject(this)
1970 }
1971
1972 getTruncatedDescription () {
1973 if (!this.description) return null
1974
1975 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1976 return peertubeTruncate(this.description, { length: maxLength })
1977 }
1978
1979 getMaxQualityResolution () {
1980 const file = this.getMaxQualityFile()
1981 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1982 const originalFilePath = getVideoFilePath(videoOrPlaylist, file)
1983
1984 return getVideoFileResolution(originalFilePath)
1985 }
1986
1987 getDescriptionAPIPath () {
1988 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1989 }
1990
1991 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1992 if (!this.VideoStreamingPlaylists) return undefined
1993
1994 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1995 playlist.Video = this
1996
1997 return playlist
1998 }
1999
2000 setHLSPlaylist (playlist: MStreamingPlaylist) {
2001 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
2002
2003 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
2004 this.VideoStreamingPlaylists = toAdd
2005 return
2006 }
2007
2008 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
2009 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
2010 .concat(toAdd)
2011 }
2012
2013 removeFile (videoFile: MVideoFile, isRedundancy = false) {
2014 const filePath = getVideoFilePath(this, videoFile, isRedundancy)
2015 return remove(filePath)
2016 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
2017 }
2018
2019 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
2020 const directoryPath = getHLSDirectory(this, isRedundancy)
2021
2022 await remove(directoryPath)
2023
2024 if (isRedundancy !== true) {
2025 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
2026 streamingPlaylistWithFiles.Video = this
2027
2028 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
2029 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
2030 }
2031
2032 // Remove physical files and torrents
2033 await Promise.all(
2034 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
2035 )
2036 }
2037 }
2038
2039 isOutdated () {
2040 if (this.isOwned()) return false
2041
2042 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
2043 }
2044
2045 hasPrivacyForFederation () {
2046 return isPrivacyForFederation(this.privacy)
2047 }
2048
2049 hasStateForFederation () {
2050 return isStateForFederation(this.state)
2051 }
2052
2053 isNewVideo (newPrivacy: VideoPrivacy) {
2054 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
2055 }
2056
2057 setAsRefreshed () {
2058 return setAsUpdated('video', this.id)
2059 }
2060
2061 requiresAuth () {
2062 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
2063 }
2064
2065 setPrivacy (newPrivacy: VideoPrivacy) {
2066 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
2067 this.publishedAt = new Date()
2068 }
2069
2070 this.privacy = newPrivacy
2071 }
2072
2073 isConfidential () {
2074 return this.privacy === VideoPrivacy.PRIVATE ||
2075 this.privacy === VideoPrivacy.UNLISTED ||
2076 this.privacy === VideoPrivacy.INTERNAL
2077 }
2078
2079 async publishIfNeededAndSave (t: Transaction) {
2080 if (this.state !== VideoState.PUBLISHED) {
2081 this.state = VideoState.PUBLISHED
2082 this.publishedAt = new Date()
2083 await this.save({ transaction: t })
2084
2085 return true
2086 }
2087
2088 return false
2089 }
2090
2091 getBandwidthBits (videoFile: MVideoFile) {
2092 return Math.ceil((videoFile.size * 8) / this.duration)
2093 }
2094
2095 getTrackerUrls () {
2096 if (this.isOwned()) {
2097 return [
2098 WEBSERVER.URL + '/tracker/announce',
2099 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
2100 ]
2101 }
2102
2103 return this.Trackers.map(t => t.url)
2104 }
2105 }