]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Refactor transcoding job handlers
[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 (options: {
1008 accountId: number
1009 start: number
1010 count: number
1011 sort: string
1012 search?: string
1013 }) {
1014 const { accountId, start, count, sort, search } = options
1015
1016 function buildBaseQuery (): FindOptions {
1017 let baseQuery = {
1018 offset: start,
1019 limit: count,
1020 order: getVideoSort(sort),
1021 include: [
1022 {
1023 model: VideoChannelModel,
1024 required: true,
1025 include: [
1026 {
1027 model: AccountModel,
1028 where: {
1029 id: accountId
1030 },
1031 required: true
1032 }
1033 ]
1034 }
1035 ]
1036 }
1037
1038 if (search) {
1039 baseQuery = Object.assign(baseQuery, {
1040 where: {
1041 name: {
1042 [Op.iLike]: '%' + search + '%'
1043 }
1044 }
1045 })
1046 }
1047
1048 return baseQuery
1049 }
1050
1051 const countQuery = buildBaseQuery()
1052 const findQuery = buildBaseQuery()
1053
1054 const findScopes: (string | ScopeOptions)[] = [
1055 ScopeNames.WITH_SCHEDULED_UPDATE,
1056 ScopeNames.WITH_BLACKLISTED,
1057 ScopeNames.WITH_THUMBNAILS
1058 ]
1059
1060 return Promise.all([
1061 VideoModel.count(countQuery),
1062 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1063 ]).then(([ count, rows ]) => {
1064 return {
1065 data: rows,
1066 total: count
1067 }
1068 })
1069 }
1070
1071 static async listForApi (options: {
1072 start: number
1073 count: number
1074 sort: string
1075 nsfw: boolean
1076 includeLocalVideos: boolean
1077 withFiles: boolean
1078 categoryOneOf?: number[]
1079 licenceOneOf?: number[]
1080 languageOneOf?: string[]
1081 tagsOneOf?: string[]
1082 tagsAllOf?: string[]
1083 filter?: VideoFilter
1084 accountId?: number
1085 videoChannelId?: number
1086 followerActorId?: number
1087 videoPlaylistId?: number
1088 trendingDays?: number
1089 user?: MUserAccountId
1090 historyOfUser?: MUserId
1091 countVideos?: boolean
1092 search?: string
1093 }) {
1094 if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1095 throw new Error('Try to filter all-local but no user has not the see all videos right')
1096 }
1097
1098 const trendingDays = options.sort.endsWith('trending')
1099 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1100 : undefined
1101
1102 const serverActor = await getServerActor()
1103
1104 // followerActorId === null has a meaning, so just check undefined
1105 const followerActorId = options.followerActorId !== undefined
1106 ? options.followerActorId
1107 : serverActor.id
1108
1109 const queryOptions = {
1110 start: options.start,
1111 count: options.count,
1112 sort: options.sort,
1113 followerActorId,
1114 serverAccountId: serverActor.Account.id,
1115 nsfw: options.nsfw,
1116 categoryOneOf: options.categoryOneOf,
1117 licenceOneOf: options.licenceOneOf,
1118 languageOneOf: options.languageOneOf,
1119 tagsOneOf: options.tagsOneOf,
1120 tagsAllOf: options.tagsAllOf,
1121 filter: options.filter,
1122 withFiles: options.withFiles,
1123 accountId: options.accountId,
1124 videoChannelId: options.videoChannelId,
1125 videoPlaylistId: options.videoPlaylistId,
1126 includeLocalVideos: options.includeLocalVideos,
1127 user: options.user,
1128 historyOfUser: options.historyOfUser,
1129 trendingDays,
1130 search: options.search
1131 }
1132
1133 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1134 }
1135
1136 static async searchAndPopulateAccountAndServer (options: {
1137 includeLocalVideos: boolean
1138 search?: string
1139 start?: number
1140 count?: number
1141 sort?: string
1142 startDate?: string // ISO 8601
1143 endDate?: string // ISO 8601
1144 originallyPublishedStartDate?: string
1145 originallyPublishedEndDate?: string
1146 nsfw?: boolean
1147 categoryOneOf?: number[]
1148 licenceOneOf?: number[]
1149 languageOneOf?: string[]
1150 tagsOneOf?: string[]
1151 tagsAllOf?: string[]
1152 durationMin?: number // seconds
1153 durationMax?: number // seconds
1154 user?: MUserAccountId
1155 filter?: VideoFilter
1156 }) {
1157 const serverActor = await getServerActor()
1158 const queryOptions = {
1159 followerActorId: serverActor.id,
1160 serverAccountId: serverActor.Account.id,
1161 includeLocalVideos: options.includeLocalVideos,
1162 nsfw: options.nsfw,
1163 categoryOneOf: options.categoryOneOf,
1164 licenceOneOf: options.licenceOneOf,
1165 languageOneOf: options.languageOneOf,
1166 tagsOneOf: options.tagsOneOf,
1167 tagsAllOf: options.tagsAllOf,
1168 user: options.user,
1169 filter: options.filter,
1170 start: options.start,
1171 count: options.count,
1172 sort: options.sort,
1173 startDate: options.startDate,
1174 endDate: options.endDate,
1175 originallyPublishedStartDate: options.originallyPublishedStartDate,
1176 originallyPublishedEndDate: options.originallyPublishedEndDate,
1177
1178 durationMin: options.durationMin,
1179 durationMax: options.durationMax,
1180
1181 search: options.search
1182 }
1183
1184 return VideoModel.getAvailableForApi(queryOptions)
1185 }
1186
1187 static countLocalLives () {
1188 const options = {
1189 where: {
1190 remote: false,
1191 isLive: true,
1192 state: {
1193 [Op.ne]: VideoState.LIVE_ENDED
1194 }
1195 }
1196 }
1197
1198 return VideoModel.count(options)
1199 }
1200
1201 static countLivesOfAccount (accountId: number) {
1202 const options = {
1203 where: {
1204 remote: false,
1205 isLive: true,
1206 state: {
1207 [Op.ne]: VideoState.LIVE_ENDED
1208 }
1209 },
1210 include: [
1211 {
1212 required: true,
1213 model: VideoChannelModel.unscoped(),
1214 where: {
1215 accountId
1216 }
1217 }
1218 ]
1219 }
1220
1221 return VideoModel.count(options)
1222 }
1223
1224 static load (id: number | string, t?: Transaction): Promise<MVideoThumbnail> {
1225 const where = buildWhereIdOrUUID(id)
1226 const options = {
1227 where,
1228 transaction: t
1229 }
1230
1231 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1232 }
1233
1234 static loadWithBlacklist (id: number | string, t?: Transaction): Promise<MVideoThumbnailBlacklist> {
1235 const where = buildWhereIdOrUUID(id)
1236 const options = {
1237 where,
1238 transaction: t
1239 }
1240
1241 return VideoModel.scope([
1242 ScopeNames.WITH_THUMBNAILS,
1243 ScopeNames.WITH_BLACKLISTED
1244 ]).findOne(options)
1245 }
1246
1247 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1248 const fun = () => {
1249 const query = {
1250 where: buildWhereIdOrUUID(id),
1251 transaction: t
1252 }
1253
1254 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1255 }
1256
1257 return ModelCache.Instance.doCache({
1258 cacheType: 'load-video-immutable-id',
1259 key: '' + id,
1260 deleteKey: 'video',
1261 fun
1262 })
1263 }
1264
1265 static loadWithRights (id: number | string, t?: Transaction): Promise<MVideoWithRights> {
1266 const where = buildWhereIdOrUUID(id)
1267 const options = {
1268 where,
1269 transaction: t
1270 }
1271
1272 return VideoModel.scope([
1273 ScopeNames.WITH_BLACKLISTED,
1274 ScopeNames.WITH_USER_ID,
1275 ScopeNames.WITH_THUMBNAILS
1276 ]).findOne(options)
1277 }
1278
1279 static loadOnlyId (id: number | string, t?: Transaction): Promise<MVideoIdThumbnail> {
1280 const where = buildWhereIdOrUUID(id)
1281
1282 const options = {
1283 attributes: [ 'id' ],
1284 where,
1285 transaction: t
1286 }
1287
1288 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1289 }
1290
1291 static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1292 const where = buildWhereIdOrUUID(id)
1293
1294 const query = {
1295 where,
1296 transaction: t,
1297 logging
1298 }
1299
1300 return VideoModel.scope([
1301 ScopeNames.WITH_WEBTORRENT_FILES,
1302 ScopeNames.WITH_STREAMING_PLAYLISTS,
1303 ScopeNames.WITH_THUMBNAILS
1304 ]).findOne(query)
1305 }
1306
1307 static loadByUUID (uuid: string): Promise<MVideoThumbnail> {
1308 const options = {
1309 where: {
1310 uuid
1311 }
1312 }
1313
1314 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1315 }
1316
1317 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1318 const query: FindOptions = {
1319 where: {
1320 url
1321 },
1322 transaction
1323 }
1324
1325 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1326 }
1327
1328 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1329 const fun = () => {
1330 const query: FindOptions = {
1331 where: {
1332 url
1333 },
1334 transaction
1335 }
1336
1337 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1338 }
1339
1340 return ModelCache.Instance.doCache({
1341 cacheType: 'load-video-immutable-url',
1342 key: url,
1343 deleteKey: 'video',
1344 fun
1345 })
1346 }
1347
1348 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1349 const query: FindOptions = {
1350 where: {
1351 url
1352 },
1353 transaction
1354 }
1355
1356 return VideoModel.scope([
1357 ScopeNames.WITH_ACCOUNT_DETAILS,
1358 ScopeNames.WITH_WEBTORRENT_FILES,
1359 ScopeNames.WITH_STREAMING_PLAYLISTS,
1360 ScopeNames.WITH_THUMBNAILS,
1361 ScopeNames.WITH_BLACKLISTED
1362 ]).findOne(query)
1363 }
1364
1365 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1366 const where = buildWhereIdOrUUID(id)
1367
1368 const options = {
1369 order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1370 where,
1371 transaction: t
1372 }
1373
1374 const scopes: (string | ScopeOptions)[] = [
1375 ScopeNames.WITH_TAGS,
1376 ScopeNames.WITH_BLACKLISTED,
1377 ScopeNames.WITH_ACCOUNT_DETAILS,
1378 ScopeNames.WITH_SCHEDULED_UPDATE,
1379 ScopeNames.WITH_WEBTORRENT_FILES,
1380 ScopeNames.WITH_STREAMING_PLAYLISTS,
1381 ScopeNames.WITH_THUMBNAILS,
1382 ScopeNames.WITH_LIVE
1383 ]
1384
1385 if (userId) {
1386 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1387 }
1388
1389 return VideoModel
1390 .scope(scopes)
1391 .findOne(options)
1392 }
1393
1394 static loadForGetAPI (parameters: {
1395 id: number | string
1396 t?: Transaction
1397 userId?: number
1398 }): Promise<MVideoDetails> {
1399 const { id, t, userId } = parameters
1400 const where = buildWhereIdOrUUID(id)
1401
1402 const options = {
1403 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1404 where,
1405 transaction: t
1406 }
1407
1408 const scopes: (string | ScopeOptions)[] = [
1409 ScopeNames.WITH_TAGS,
1410 ScopeNames.WITH_BLACKLISTED,
1411 ScopeNames.WITH_ACCOUNT_DETAILS,
1412 ScopeNames.WITH_SCHEDULED_UPDATE,
1413 ScopeNames.WITH_THUMBNAILS,
1414 ScopeNames.WITH_LIVE,
1415 { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
1416 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1417 ]
1418
1419 if (userId) {
1420 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1421 }
1422
1423 return VideoModel
1424 .scope(scopes)
1425 .findOne(options)
1426 }
1427
1428 static async getStats () {
1429 const totalLocalVideos = await VideoModel.count({
1430 where: {
1431 remote: false
1432 }
1433 })
1434
1435 let totalLocalVideoViews = await VideoModel.sum('views', {
1436 where: {
1437 remote: false
1438 }
1439 })
1440
1441 // Sequelize could return null...
1442 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1443
1444 const { total: totalVideos } = await VideoModel.listForApi({
1445 start: 0,
1446 count: 0,
1447 sort: '-publishedAt',
1448 nsfw: buildNSFWFilter(),
1449 includeLocalVideos: true,
1450 withFiles: false
1451 })
1452
1453 return {
1454 totalLocalVideos,
1455 totalLocalVideoViews,
1456 totalVideos
1457 }
1458 }
1459
1460 static incrementViews (id: number, views: number) {
1461 return VideoModel.increment('views', {
1462 by: views,
1463 where: {
1464 id
1465 }
1466 })
1467 }
1468
1469 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1470 // Instances only share videos
1471 const query = 'SELECT 1 FROM "videoShare" ' +
1472 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1473 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1474 'LIMIT 1'
1475
1476 const options = {
1477 type: QueryTypes.SELECT as QueryTypes.SELECT,
1478 bind: { followerActorId, videoId },
1479 raw: true
1480 }
1481
1482 return VideoModel.sequelize.query(query, options)
1483 .then(results => results.length === 1)
1484 }
1485
1486 static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) {
1487 const options = {
1488 where: {
1489 channelId: videoChannel.id
1490 },
1491 transaction: t
1492 }
1493
1494 return VideoModel.update({ support: videoChannel.support }, options)
1495 }
1496
1497 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1498 const query = {
1499 attributes: [ 'id' ],
1500 where: {
1501 channelId: videoChannel.id
1502 }
1503 }
1504
1505 return VideoModel.findAll(query)
1506 .then(videos => videos.map(v => v.id))
1507 }
1508
1509 // threshold corresponds to how many video the field should have to be returned
1510 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1511 const serverActor = await getServerActor()
1512 const followerActorId = serverActor.id
1513
1514 const queryOptions: BuildVideosQueryOptions = {
1515 attributes: [ `"${field}"` ],
1516 group: `GROUP BY "${field}"`,
1517 having: `HAVING COUNT("${field}") >= ${threshold}`,
1518 start: 0,
1519 sort: 'random',
1520 count,
1521 serverAccountId: serverActor.Account.id,
1522 followerActorId,
1523 includeLocalVideos: true
1524 }
1525
1526 const { query, replacements } = buildListQuery(VideoModel, queryOptions)
1527
1528 return this.sequelize.query<any>(query, { replacements, type: QueryTypes.SELECT })
1529 .then(rows => rows.map(r => r[field]))
1530 }
1531
1532 static buildTrendingQuery (trendingDays: number) {
1533 return {
1534 attributes: [],
1535 subQuery: false,
1536 model: VideoViewModel,
1537 required: false,
1538 where: {
1539 startDate: {
1540 [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1541 }
1542 }
1543 }
1544 }
1545
1546 private static async getAvailableForApi (
1547 options: BuildVideosQueryOptions,
1548 countVideos = true
1549 ): Promise<ResultList<VideoModel>> {
1550 function getCount () {
1551 if (countVideos !== true) return Promise.resolve(undefined)
1552
1553 const countOptions = Object.assign({}, options, { isCount: true })
1554 const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel, countOptions)
1555
1556 return VideoModel.sequelize.query<any>(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT })
1557 .then(rows => rows.length !== 0 ? rows[0].total : 0)
1558 }
1559
1560 function getModels () {
1561 if (options.count === 0) return Promise.resolve([])
1562
1563 const { query, replacements, order } = buildListQuery(VideoModel, options)
1564 const queryModels = wrapForAPIResults(query, replacements, options, order)
1565
1566 return VideoModel.sequelize.query<any>(queryModels, { replacements, type: QueryTypes.SELECT, nest: true })
1567 .then(rows => VideoModel.buildAPIResult(rows))
1568 }
1569
1570 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1571
1572 return {
1573 data: rows,
1574 total: count
1575 }
1576 }
1577
1578 private static buildAPIResult (rows: any[]) {
1579 const videosMemo: { [ id: number ]: VideoModel } = {}
1580 const videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel } = {}
1581
1582 const thumbnailsDone = new Set<number>()
1583 const historyDone = new Set<number>()
1584 const videoFilesDone = new Set<number>()
1585
1586 const videos: VideoModel[] = []
1587
1588 const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ]
1589 const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ]
1590 const serverKeys = [ 'id', 'host' ]
1591 const videoFileKeys = [ 'id', 'createdAt', 'updatedAt', 'resolution', 'size', 'extname', 'infoHash', 'fps', 'videoId' ]
1592 const videoStreamingPlaylistKeys = [ 'id' ]
1593 const videoKeys = [
1594 'id',
1595 'uuid',
1596 'name',
1597 'category',
1598 'licence',
1599 'language',
1600 'privacy',
1601 'nsfw',
1602 'description',
1603 'support',
1604 'duration',
1605 'views',
1606 'likes',
1607 'dislikes',
1608 'remote',
1609 'isLive',
1610 'url',
1611 'commentsEnabled',
1612 'downloadEnabled',
1613 'waitTranscoding',
1614 'state',
1615 'publishedAt',
1616 'originallyPublishedAt',
1617 'channelId',
1618 'createdAt',
1619 'updatedAt'
1620 ]
1621
1622 function buildActor (rowActor: any) {
1623 const avatarModel = rowActor.Avatar.id !== null
1624 ? new AvatarModel(pick(rowActor.Avatar, avatarKeys))
1625 : null
1626
1627 const serverModel = rowActor.Server.id !== null
1628 ? new ServerModel(pick(rowActor.Server, serverKeys))
1629 : null
1630
1631 const actorModel = new ActorModel(pick(rowActor, actorKeys))
1632 actorModel.Avatar = avatarModel
1633 actorModel.Server = serverModel
1634
1635 return actorModel
1636 }
1637
1638 for (const row of rows) {
1639 if (!videosMemo[row.id]) {
1640 // Build Channel
1641 const channel = row.VideoChannel
1642 const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ]))
1643 channelModel.Actor = buildActor(channel.Actor)
1644
1645 const account = row.VideoChannel.Account
1646 const accountModel = new AccountModel(pick(account, [ 'id', 'name' ]))
1647 accountModel.Actor = buildActor(account.Actor)
1648
1649 channelModel.Account = accountModel
1650
1651 const videoModel = new VideoModel(pick(row, videoKeys))
1652 videoModel.VideoChannel = channelModel
1653
1654 videoModel.UserVideoHistories = []
1655 videoModel.Thumbnails = []
1656 videoModel.VideoFiles = []
1657 videoModel.VideoStreamingPlaylists = []
1658
1659 videosMemo[row.id] = videoModel
1660 // Don't take object value to have a sorted array
1661 videos.push(videoModel)
1662 }
1663
1664 const videoModel = videosMemo[row.id]
1665
1666 if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) {
1667 const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ]))
1668 videoModel.UserVideoHistories.push(historyModel)
1669
1670 historyDone.add(row.userVideoHistory.id)
1671 }
1672
1673 if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) {
1674 const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ]))
1675 videoModel.Thumbnails.push(thumbnailModel)
1676
1677 thumbnailsDone.add(row.Thumbnails.id)
1678 }
1679
1680 if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) {
1681 const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys))
1682 videoModel.VideoFiles.push(videoFileModel)
1683
1684 videoFilesDone.add(row.VideoFiles.id)
1685 }
1686
1687 if (row.VideoStreamingPlaylists?.id && !videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]) {
1688 const streamingPlaylist = new VideoStreamingPlaylistModel(pick(row.VideoStreamingPlaylists, videoStreamingPlaylistKeys))
1689 streamingPlaylist.VideoFiles = []
1690
1691 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
1692
1693 videoStreamingPlaylistMemo[streamingPlaylist.id] = streamingPlaylist
1694 }
1695
1696 if (row.VideoStreamingPlaylists?.VideoFiles?.id && !videoFilesDone.has(row.VideoStreamingPlaylists.VideoFiles.id)) {
1697 const streamingPlaylist = videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]
1698
1699 const videoFileModel = new VideoFileModel(pick(row.VideoStreamingPlaylists.VideoFiles, videoFileKeys))
1700 streamingPlaylist.VideoFiles.push(videoFileModel)
1701
1702 videoFilesDone.add(row.VideoStreamingPlaylists.VideoFiles.id)
1703 }
1704 }
1705
1706 return videos
1707 }
1708
1709 static getCategoryLabel (id: number) {
1710 return VIDEO_CATEGORIES[id] || 'Misc'
1711 }
1712
1713 static getLicenceLabel (id: number) {
1714 return VIDEO_LICENCES[id] || 'Unknown'
1715 }
1716
1717 static getLanguageLabel (id: string) {
1718 return VIDEO_LANGUAGES[id] || 'Unknown'
1719 }
1720
1721 static getPrivacyLabel (id: number) {
1722 return VIDEO_PRIVACIES[id] || 'Unknown'
1723 }
1724
1725 static getStateLabel (id: number) {
1726 return VIDEO_STATES[id] || 'Unknown'
1727 }
1728
1729 isBlacklisted () {
1730 return !!this.VideoBlacklist
1731 }
1732
1733 isBlocked () {
1734 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1735 }
1736
1737 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1738 // We first transcode to WebTorrent format, so try this array first
1739 if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1740 const file = fun(this.VideoFiles, file => file.resolution)
1741
1742 return Object.assign(file, { Video: this })
1743 }
1744
1745 // No webtorrent files, try with streaming playlist files
1746 if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1747 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1748
1749 const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1750 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1751 }
1752
1753 return undefined
1754 }
1755
1756 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1757 return this.getQualityFileBy(maxBy)
1758 }
1759
1760 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1761 return this.getQualityFileBy(minBy)
1762 }
1763
1764 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1765 if (Array.isArray(this.VideoFiles) === false) return undefined
1766
1767 const file = this.VideoFiles.find(f => f.resolution === resolution)
1768 if (!file) return undefined
1769
1770 return Object.assign(file, { Video: this })
1771 }
1772
1773 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) {
1774 thumbnail.videoId = this.id
1775
1776 const savedThumbnail = await thumbnail.save({ transaction })
1777
1778 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1779
1780 // Already have this thumbnail, skip
1781 if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1782
1783 this.Thumbnails.push(savedThumbnail)
1784 }
1785
1786 generateThumbnailName () {
1787 return this.uuid + '.jpg'
1788 }
1789
1790 getMiniature () {
1791 if (Array.isArray(this.Thumbnails) === false) return undefined
1792
1793 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1794 }
1795
1796 generatePreviewName () {
1797 return this.uuid + '.jpg'
1798 }
1799
1800 hasPreview () {
1801 return !!this.getPreview()
1802 }
1803
1804 getPreview () {
1805 if (Array.isArray(this.Thumbnails) === false) return undefined
1806
1807 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1808 }
1809
1810 isOwned () {
1811 return this.remote === false
1812 }
1813
1814 getWatchStaticPath () {
1815 return '/videos/watch/' + this.uuid
1816 }
1817
1818 getEmbedStaticPath () {
1819 return '/videos/embed/' + this.uuid
1820 }
1821
1822 getMiniatureStaticPath () {
1823 const thumbnail = this.getMiniature()
1824 if (!thumbnail) return null
1825
1826 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1827 }
1828
1829 getPreviewStaticPath () {
1830 const preview = this.getPreview()
1831 if (!preview) return null
1832
1833 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1834 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1835 }
1836
1837 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1838 return videoModelToFormattedJSON(this, options)
1839 }
1840
1841 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1842 return videoModelToFormattedDetailsJSON(this)
1843 }
1844
1845 getFormattedVideoFilesJSON (): VideoFile[] {
1846 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1847 let files: MVideoFileRedundanciesOpt[] = []
1848
1849 if (Array.isArray(this.VideoFiles)) {
1850 files = files.concat(this.VideoFiles)
1851 }
1852
1853 for (const p of (this.VideoStreamingPlaylists || [])) {
1854 files = files.concat(p.VideoFiles || [])
1855 }
1856
1857 return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, files)
1858 }
1859
1860 toActivityPubObject (this: MVideoAP): VideoObject {
1861 return videoModelToActivityPubObject(this)
1862 }
1863
1864 getTruncatedDescription () {
1865 if (!this.description) return null
1866
1867 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1868 return peertubeTruncate(this.description, { length: maxLength })
1869 }
1870
1871 getMaxQualityResolution () {
1872 const file = this.getMaxQualityFile()
1873 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1874 const originalFilePath = getVideoFilePath(videoOrPlaylist, file)
1875
1876 return getVideoFileResolution(originalFilePath)
1877 }
1878
1879 getDescriptionAPIPath () {
1880 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1881 }
1882
1883 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1884 if (!this.VideoStreamingPlaylists) return undefined
1885
1886 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1887 playlist.Video = this
1888
1889 return playlist
1890 }
1891
1892 setHLSPlaylist (playlist: MStreamingPlaylist) {
1893 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1894
1895 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1896 this.VideoStreamingPlaylists = toAdd
1897 return
1898 }
1899
1900 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1901 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1902 .concat(toAdd)
1903 }
1904
1905 removeFile (videoFile: MVideoFile, isRedundancy = false) {
1906 const filePath = getVideoFilePath(this, videoFile, isRedundancy)
1907 return remove(filePath)
1908 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1909 }
1910
1911 removeTorrent (videoFile: MVideoFile) {
1912 const torrentPath = getTorrentFilePath(this, videoFile)
1913 return remove(torrentPath)
1914 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1915 }
1916
1917 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1918 const directoryPath = getHLSDirectory(this, isRedundancy)
1919
1920 await remove(directoryPath)
1921
1922 if (isRedundancy !== true) {
1923 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1924 streamingPlaylistWithFiles.Video = this
1925
1926 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1927 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1928 }
1929
1930 // Remove physical files and torrents
1931 await Promise.all(
1932 streamingPlaylistWithFiles.VideoFiles.map(file => streamingPlaylistWithFiles.removeTorrent(file))
1933 )
1934 }
1935 }
1936
1937 isOutdated () {
1938 if (this.isOwned()) return false
1939
1940 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1941 }
1942
1943 hasPrivacyForFederation () {
1944 return isPrivacyForFederation(this.privacy)
1945 }
1946
1947 hasStateForFederation () {
1948 return isStateForFederation(this.state)
1949 }
1950
1951 isNewVideo (newPrivacy: VideoPrivacy) {
1952 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1953 }
1954
1955 setAsRefreshed () {
1956 this.changed('updatedAt', true)
1957
1958 return this.save()
1959 }
1960
1961 requiresAuth () {
1962 return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
1963 }
1964
1965 setPrivacy (newPrivacy: VideoPrivacy) {
1966 if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
1967 this.publishedAt = new Date()
1968 }
1969
1970 this.privacy = newPrivacy
1971 }
1972
1973 isConfidential () {
1974 return this.privacy === VideoPrivacy.PRIVATE ||
1975 this.privacy === VideoPrivacy.UNLISTED ||
1976 this.privacy === VideoPrivacy.INTERNAL
1977 }
1978
1979 async publishIfNeededAndSave (t: Transaction) {
1980 if (this.state !== VideoState.PUBLISHED) {
1981 this.state = VideoState.PUBLISHED
1982 this.publishedAt = new Date()
1983 await this.save({ transaction: t })
1984
1985 return true
1986 }
1987
1988 return false
1989 }
1990
1991 getBaseUrls () {
1992 if (this.isOwned()) {
1993 return {
1994 baseUrlHttp: WEBSERVER.URL,
1995 baseUrlWs: WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
1996 }
1997 }
1998
1999 return {
2000 baseUrlHttp: REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host,
2001 baseUrlWs: REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
2002 }
2003 }
2004
2005 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
2006 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
2007 }
2008
2009 getTorrentUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2010 return baseUrlHttp + STATIC_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
2011 }
2012
2013 getTorrentDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2014 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
2015 }
2016
2017 getVideoFileUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2018 return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile)
2019 }
2020
2021 getVideoFileMetadataUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2022 const path = '/api/v1/videos/'
2023
2024 return this.isOwned()
2025 ? baseUrlHttp + path + this.uuid + '/metadata/' + videoFile.id
2026 : videoFile.metadataUrl
2027 }
2028
2029 getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2030 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile)
2031 }
2032
2033 getVideoFileDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2034 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + getVideoFilename(this, videoFile)
2035 }
2036
2037 getBandwidthBits (videoFile: MVideoFile) {
2038 return Math.ceil((videoFile.size * 8) / this.duration)
2039 }
2040 }