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