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