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