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