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