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