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