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