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