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