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