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