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