aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
Diffstat (limited to 'server/models')
-rw-r--r--server/models/abuse/abuse-message.ts3
-rw-r--r--server/models/abuse/abuse.ts4
-rw-r--r--server/models/abuse/video-abuse.ts3
-rw-r--r--server/models/abuse/video-comment-abuse.ts3
-rw-r--r--server/models/account/account-blocklist.ts5
-rw-r--r--server/models/account/account-video-rate.ts5
-rw-r--r--server/models/account/account.ts13
-rw-r--r--server/models/account/actor-custom-page.ts69
-rw-r--r--server/models/actor/actor-follow.ts (renamed from server/models/activitypub/actor-follow.ts)5
-rw-r--r--server/models/actor/actor-image.ts (renamed from server/models/account/actor-image.ts)7
-rw-r--r--server/models/actor/actor.ts (renamed from server/models/activitypub/actor.ts)16
-rw-r--r--server/models/application/application.ts5
-rw-r--r--server/models/oauth/oauth-client.ts3
-rw-r--r--server/models/oauth/oauth-token.ts7
-rw-r--r--server/models/redundancy/video-redundancy.ts8
-rw-r--r--server/models/server/plugin.ts7
-rw-r--r--server/models/server/server-blocklist.ts3
-rw-r--r--server/models/server/server.ts11
-rw-r--r--server/models/server/tracker.ts3
-rw-r--r--server/models/server/video-tracker.ts3
-rw-r--r--server/models/user/user-notification-setting.ts (renamed from server/models/account/user-notification-setting.ts)3
-rw-r--r--server/models/user/user-notification.ts (renamed from server/models/account/user-notification.ts)11
-rw-r--r--server/models/user/user-video-history.ts (renamed from server/models/account/user-video-history.ts)7
-rw-r--r--server/models/user/user.ts (renamed from server/models/account/user.ts)11
-rw-r--r--server/models/utils.ts11
-rw-r--r--server/models/video/formatter/video-format-utils.ts (renamed from server/models/video/video-format-utils.ts)78
-rw-r--r--server/models/video/schedule-video-update.ts29
-rw-r--r--server/models/video/sql/shared/abstract-videos-model-query-builder.ts300
-rw-r--r--server/models/video/sql/shared/abstract-videos-query-builder.ts26
-rw-r--r--server/models/video/sql/shared/video-file-query-builder.ts69
-rw-r--r--server/models/video/sql/shared/video-model-builder.ts333
-rw-r--r--server/models/video/sql/shared/video-tables.ts263
-rw-r--r--server/models/video/sql/video-model-get-query-builder.ts173
-rw-r--r--server/models/video/sql/videos-id-list-query-builder.ts616
-rw-r--r--server/models/video/sql/videos-model-list-query-builder.ts71
-rw-r--r--server/models/video/tag.ts3
-rw-r--r--server/models/video/thumbnail.ts3
-rw-r--r--server/models/video/video-blacklist.ts3
-rw-r--r--server/models/video/video-caption.ts30
-rw-r--r--server/models/video/video-change-ownership.ts3
-rw-r--r--server/models/video/video-channel.ts20
-rw-r--r--server/models/video/video-comment.ts21
-rw-r--r--server/models/video/video-file.ts3
-rw-r--r--server/models/video/video-import.ts7
-rw-r--r--server/models/video/video-live.ts3
-rw-r--r--server/models/video/video-playlist-element.ts6
-rw-r--r--server/models/video/video-playlist.ts100
-rw-r--r--server/models/video/video-query-builder.ts599
-rw-r--r--server/models/video/video-share.ts5
-rw-r--r--server/models/video/video-streaming-playlist.ts3
-rw-r--r--server/models/video/video-tag.ts3
-rw-r--r--server/models/video/video-view.ts5
-rw-r--r--server/models/video/video.ts498
53 files changed, 2317 insertions, 1182 deletions
diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts
index 7e51b3e07..2c5987e96 100644
--- a/server/models/abuse/abuse-message.ts
+++ b/server/models/abuse/abuse-message.ts
@@ -1,6 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses' 2import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses'
3import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models' 3import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { AbuseMessage } from '@shared/models' 5import { AbuseMessage } from '@shared/models'
5import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' 6import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
6import { getSort, throwIfNotValid } from '../utils' 7import { getSort, throwIfNotValid } from '../utils'
@@ -17,7 +18,7 @@ import { AbuseModel } from './abuse'
17 } 18 }
18 ] 19 ]
19}) 20})
20export class AbuseMessageModel extends Model { 21export class AbuseMessageModel extends Model<Partial<AttributesOnly<AbuseMessageModel>>> {
21 22
22 @AllowNull(false) 23 @AllowNull(false)
23 @Is('AbuseMessage', value => throwIfNotValid(value, isAbuseMessageValid, 'message')) 24 @Is('AbuseMessage', value => throwIfNotValid(value, isAbuseMessageValid, 'message'))
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
index 262f364f1..3518f5c02 100644
--- a/server/models/abuse/abuse.ts
+++ b/server/models/abuse/abuse.ts
@@ -16,7 +16,7 @@ import {
16 UpdatedAt 16 UpdatedAt
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses' 18import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
19import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse' 19import { abusePredefinedReasonsMap, AttributesOnly } from '@shared/core-utils'
20import { 20import {
21 AbuseFilter, 21 AbuseFilter,
22 AbuseObject, 22 AbuseObject,
@@ -187,7 +187,7 @@ export enum ScopeNames {
187 } 187 }
188 ] 188 ]
189}) 189})
190export class AbuseModel extends Model { 190export class AbuseModel extends Model<Partial<AttributesOnly<AbuseModel>>> {
191 191
192 @AllowNull(false) 192 @AllowNull(false)
193 @Default(null) 193 @Default(null)
diff --git a/server/models/abuse/video-abuse.ts b/server/models/abuse/video-abuse.ts
index 90aa0695e..95bff50d0 100644
--- a/server/models/abuse/video-abuse.ts
+++ b/server/models/abuse/video-abuse.ts
@@ -1,4 +1,5 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { VideoDetails } from '@shared/models' 3import { VideoDetails } from '@shared/models'
3import { VideoModel } from '../video/video' 4import { VideoModel } from '../video/video'
4import { AbuseModel } from './abuse' 5import { AbuseModel } from './abuse'
@@ -14,7 +15,7 @@ import { AbuseModel } from './abuse'
14 } 15 }
15 ] 16 ]
16}) 17})
17export class VideoAbuseModel extends Model { 18export class VideoAbuseModel extends Model<Partial<AttributesOnly<VideoAbuseModel>>> {
18 19
19 @CreatedAt 20 @CreatedAt
20 createdAt: Date 21 createdAt: Date
diff --git a/server/models/abuse/video-comment-abuse.ts b/server/models/abuse/video-comment-abuse.ts
index d3fce76a5..32cb2ca64 100644
--- a/server/models/abuse/video-comment-abuse.ts
+++ b/server/models/abuse/video-comment-abuse.ts
@@ -1,4 +1,5 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { VideoCommentModel } from '../video/video-comment' 3import { VideoCommentModel } from '../video/video-comment'
3import { AbuseModel } from './abuse' 4import { AbuseModel } from './abuse'
4 5
@@ -13,7 +14,7 @@ import { AbuseModel } from './abuse'
13 } 14 }
14 ] 15 ]
15}) 16})
16export class VideoCommentAbuseModel extends Model { 17export class VideoCommentAbuseModel extends Model<Partial<AttributesOnly<VideoCommentAbuseModel>>> {
17 18
18 @CreatedAt 19 @CreatedAt
19 createdAt: Date 20 createdAt: Date
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index fe9168ab8..b2375b006 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -1,8 +1,9 @@
1import { Op } from 'sequelize' 1import { Op } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models' 3import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { AccountBlock } from '../../../shared/models' 5import { AccountBlock } from '../../../shared/models'
5import { ActorModel } from '../activitypub/actor' 6import { ActorModel } from '../actor/actor'
6import { ServerModel } from '../server/server' 7import { ServerModel } from '../server/server'
7import { getSort, searchAttribute } from '../utils' 8import { getSort, searchAttribute } from '../utils'
8import { AccountModel } from './account' 9import { AccountModel } from './account'
@@ -40,7 +41,7 @@ enum ScopeNames {
40 } 41 }
41 ] 42 ]
42}) 43})
43export class AccountBlocklistModel extends Model { 44export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountBlocklistModel>>> {
44 45
45 @CreatedAt 46 @CreatedAt
46 createdAt: Date 47 createdAt: Date
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index 801f76bba..ee6dbc6da 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -7,11 +7,12 @@ import {
7 MAccountVideoRateAccountVideo, 7 MAccountVideoRateAccountVideo,
8 MAccountVideoRateFormattable 8 MAccountVideoRateFormattable
9} from '@server/types/models/video/video-rate' 9} from '@server/types/models/video/video-rate'
10import { AttributesOnly } from '@shared/core-utils'
10import { AccountVideoRate } from '../../../shared' 11import { AccountVideoRate } from '../../../shared'
11import { VideoRateType } from '../../../shared/models/videos' 12import { VideoRateType } from '../../../shared/models/videos'
12import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 13import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
13import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants' 14import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
14import { ActorModel } from '../activitypub/actor' 15import { ActorModel } from '../actor/actor'
15import { buildLocalAccountIdsIn, getSort, throwIfNotValid } from '../utils' 16import { buildLocalAccountIdsIn, getSort, throwIfNotValid } from '../utils'
16import { VideoModel } from '../video/video' 17import { VideoModel } from '../video/video'
17import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel' 18import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
@@ -42,7 +43,7 @@ import { AccountModel } from './account'
42 } 43 }
43 ] 44 ]
44}) 45})
45export class AccountVideoRateModel extends Model { 46export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountVideoRateModel>>> {
46 47
47 @AllowNull(false) 48 @AllowNull(false)
48 @Column(DataType.ENUM(...values(VIDEO_RATE_TYPES))) 49 @Column(DataType.ENUM(...values(VIDEO_RATE_TYPES)))
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index d33353af7..665ecd595 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -17,10 +17,11 @@ import {
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { ModelCache } from '@server/models/model-cache' 19import { ModelCache } from '@server/models/model-cache'
20import { AttributesOnly } from '@shared/core-utils'
20import { Account, AccountSummary } from '../../../shared/models/actors' 21import { Account, AccountSummary } from '../../../shared/models/actors'
21import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
22import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants' 23import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants'
23import { sendDeleteActor } from '../../lib/activitypub/send' 24import { sendDeleteActor } from '../../lib/activitypub/send/send-delete'
24import { 25import {
25 MAccount, 26 MAccount,
26 MAccountActor, 27 MAccountActor,
@@ -30,19 +31,19 @@ import {
30 MAccountSummaryFormattable, 31 MAccountSummaryFormattable,
31 MChannelActor 32 MChannelActor
32} from '../../types/models' 33} from '../../types/models'
33import { ActorModel } from '../activitypub/actor' 34import { ActorModel } from '../actor/actor'
34import { ActorFollowModel } from '../activitypub/actor-follow' 35import { ActorFollowModel } from '../actor/actor-follow'
36import { ActorImageModel } from '../actor/actor-image'
35import { ApplicationModel } from '../application/application' 37import { ApplicationModel } from '../application/application'
36import { ActorImageModel } from './actor-image'
37import { ServerModel } from '../server/server' 38import { ServerModel } from '../server/server'
38import { ServerBlocklistModel } from '../server/server-blocklist' 39import { ServerBlocklistModel } from '../server/server-blocklist'
40import { UserModel } from '../user/user'
39import { getSort, throwIfNotValid } from '../utils' 41import { getSort, throwIfNotValid } from '../utils'
40import { VideoModel } from '../video/video' 42import { VideoModel } from '../video/video'
41import { VideoChannelModel } from '../video/video-channel' 43import { VideoChannelModel } from '../video/video-channel'
42import { VideoCommentModel } from '../video/video-comment' 44import { VideoCommentModel } from '../video/video-comment'
43import { VideoPlaylistModel } from '../video/video-playlist' 45import { VideoPlaylistModel } from '../video/video-playlist'
44import { AccountBlocklistModel } from './account-blocklist' 46import { AccountBlocklistModel } from './account-blocklist'
45import { UserModel } from './user'
46 47
47export enum ScopeNames { 48export enum ScopeNames {
48 SUMMARY = 'SUMMARY' 49 SUMMARY = 'SUMMARY'
@@ -141,7 +142,7 @@ export type SummaryOptions = {
141 } 142 }
142 ] 143 ]
143}) 144})
144export class AccountModel extends Model { 145export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
145 146
146 @AllowNull(false) 147 @AllowNull(false)
147 @Column 148 @Column
diff --git a/server/models/account/actor-custom-page.ts b/server/models/account/actor-custom-page.ts
new file mode 100644
index 000000000..893023181
--- /dev/null
+++ b/server/models/account/actor-custom-page.ts
@@ -0,0 +1,69 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { CustomPage } from '@shared/models'
3import { ActorModel } from '../actor/actor'
4import { getServerActor } from '../application/application'
5
6@Table({
7 tableName: 'actorCustomPage',
8 indexes: [
9 {
10 fields: [ 'actorId', 'type' ],
11 unique: true
12 }
13 ]
14})
15export class ActorCustomPageModel extends Model {
16
17 @AllowNull(true)
18 @Column(DataType.TEXT)
19 content: string
20
21 @AllowNull(false)
22 @Column
23 type: 'homepage'
24
25 @CreatedAt
26 createdAt: Date
27
28 @UpdatedAt
29 updatedAt: Date
30
31 @ForeignKey(() => ActorModel)
32 @Column
33 actorId: number
34
35 @BelongsTo(() => ActorModel, {
36 foreignKey: {
37 name: 'actorId',
38 allowNull: false
39 },
40 onDelete: 'cascade'
41 })
42 Actor: ActorModel
43
44 static async updateInstanceHomepage (content: string) {
45 const serverActor = await getServerActor()
46
47 return ActorCustomPageModel.upsert({
48 content,
49 actorId: serverActor.id,
50 type: 'homepage'
51 })
52 }
53
54 static async loadInstanceHomepage () {
55 const serverActor = await getServerActor()
56
57 return ActorCustomPageModel.findOne({
58 where: {
59 actorId: serverActor.id
60 }
61 })
62 }
63
64 toFormattedJSON (): CustomPage {
65 return {
66 content: this.content
67 }
68 }
69}
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/actor/actor-follow.ts
index 4c5f37620..3a09e51d6 100644
--- a/server/models/activitypub/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -28,6 +28,7 @@ import {
28 MActorFollowFormattable, 28 MActorFollowFormattable,
29 MActorFollowSubscriptions 29 MActorFollowSubscriptions
30} from '@server/types/models' 30} from '@server/types/models'
31import { AttributesOnly } from '@shared/core-utils'
31import { ActivityPubActorType } from '@shared/models' 32import { ActivityPubActorType } from '@shared/models'
32import { FollowState } from '../../../shared/models/actors' 33import { FollowState } from '../../../shared/models/actors'
33import { ActorFollow } from '../../../shared/models/actors/follow.model' 34import { ActorFollow } from '../../../shared/models/actors/follow.model'
@@ -61,7 +62,7 @@ import { ActorModel, unusedActorAttributesForAPI } from './actor'
61 } 62 }
62 ] 63 ]
63}) 64})
64export class ActorFollowModel extends Model { 65export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowModel>>> {
65 66
66 @AllowNull(false) 67 @AllowNull(false)
67 @Column(DataType.ENUM(...values(FOLLOW_STATES))) 68 @Column(DataType.ENUM(...values(FOLLOW_STATES)))
@@ -619,7 +620,7 @@ export class ActorFollowModel extends Model {
619 if (serverIds.length === 0) return 620 if (serverIds.length === 0) return
620 621
621 const me = await getServerActor() 622 const me = await getServerActor()
622 const serverIdsString = createSafeIn(ActorFollowModel, serverIds) 623 const serverIdsString = createSafeIn(ActorFollowModel.sequelize, serverIds)
623 624
624 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` + 625 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
625 'WHERE id IN (' + 626 'WHERE id IN (' +
diff --git a/server/models/account/actor-image.ts b/server/models/actor/actor-image.ts
index ae05b4969..98a7f6fba 100644
--- a/server/models/account/actor-image.ts
+++ b/server/models/actor/actor-image.ts
@@ -2,6 +2,7 @@ import { remove } from 'fs-extra'
2import { join } from 'path' 2import { join } from 'path'
3import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { MActorImageFormattable } from '@server/types/models' 4import { MActorImageFormattable } from '@server/types/models'
5import { AttributesOnly } from '@shared/core-utils'
5import { ActorImageType } from '@shared/models' 6import { ActorImageType } from '@shared/models'
6import { ActorImage } from '../../../shared/models/actors/actor-image.model' 7import { ActorImage } from '../../../shared/models/actors/actor-image.model'
7import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
@@ -19,7 +20,7 @@ import { throwIfNotValid } from '../utils'
19 } 20 }
20 ] 21 ]
21}) 22})
22export class ActorImageModel extends Model { 23export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageModel>>> {
23 24
24 @AllowNull(false) 25 @AllowNull(false)
25 @Column 26 @Column
@@ -97,4 +98,8 @@ export class ActorImageModel extends Model {
97 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES, this.filename) 98 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES, this.filename)
98 return remove(imagePath) 99 return remove(imagePath)
99 } 100 }
101
102 isOwned () {
103 return !this.fileUrl
104 }
100} 105}
diff --git a/server/models/activitypub/actor.ts b/server/models/actor/actor.ts
index 1af9efac2..8df49951d 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/actor/actor.ts
@@ -1,5 +1,4 @@
1import { values } from 'lodash' 1import { values } from 'lodash'
2import { extname } from 'path'
3import { literal, Op, Transaction } from 'sequelize' 2import { literal, Op, Transaction } from 'sequelize'
4import { 3import {
5 AllowNull, 4 AllowNull,
@@ -17,7 +16,9 @@ import {
17 Table, 16 Table,
18 UpdatedAt 17 UpdatedAt
19} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { getLowercaseExtension } from '@server/helpers/core-utils'
20import { ModelCache } from '@server/models/model-cache' 20import { ModelCache } from '@server/models/model-cache'
21import { AttributesOnly } from '@shared/core-utils'
21import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub' 22import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub'
22import { ActorImage } from '../../../shared/models/actors/actor-image.model' 23import { ActorImage } from '../../../shared/models/actors/actor-image.model'
23import { activityPubContextify } from '../../helpers/activitypub' 24import { activityPubContextify } from '../../helpers/activitypub'
@@ -51,12 +52,12 @@ import {
51 MActorWithInboxes 52 MActorWithInboxes
52} from '../../types/models' 53} from '../../types/models'
53import { AccountModel } from '../account/account' 54import { AccountModel } from '../account/account'
54import { ActorImageModel } from '../account/actor-image'
55import { ServerModel } from '../server/server' 55import { ServerModel } from '../server/server'
56import { isOutdated, throwIfNotValid } from '../utils' 56import { isOutdated, throwIfNotValid } from '../utils'
57import { VideoModel } from '../video/video' 57import { VideoModel } from '../video/video'
58import { VideoChannelModel } from '../video/video-channel' 58import { VideoChannelModel } from '../video/video-channel'
59import { ActorFollowModel } from './actor-follow' 59import { ActorFollowModel } from './actor-follow'
60import { ActorImageModel } from './actor-image'
60 61
61enum ScopeNames { 62enum ScopeNames {
62 FULL = 'FULL' 63 FULL = 'FULL'
@@ -159,7 +160,7 @@ export const unusedActorAttributesForAPI = [
159 } 160 }
160 ] 161 ]
161}) 162})
162export class ActorModel extends Model { 163export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
163 164
164 @AllowNull(false) 165 @AllowNull(false)
165 @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES))) 166 @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES)))
@@ -495,7 +496,7 @@ export class ActorModel extends Model {
495 }, { where, transaction }) 496 }, { where, transaction })
496 } 497 }
497 498
498 static loadAccountActorByVideoId (videoId: number): Promise<MActor> { 499 static loadAccountActorByVideoId (videoId: number, transaction: Transaction): Promise<MActor> {
499 const query = { 500 const query = {
500 include: [ 501 include: [
501 { 502 {
@@ -519,7 +520,8 @@ export class ActorModel extends Model {
519 } 520 }
520 ] 521 ]
521 } 522 }
522 ] 523 ],
524 transaction
523 } 525 }
524 526
525 return ActorModel.unscoped().findOne(query) 527 return ActorModel.unscoped().findOne(query)
@@ -566,7 +568,7 @@ export class ActorModel extends Model {
566 let image: ActivityIconObject 568 let image: ActivityIconObject
567 569
568 if (this.avatarId) { 570 if (this.avatarId) {
569 const extension = extname(this.Avatar.filename) 571 const extension = getLowercaseExtension(this.Avatar.filename)
570 572
571 icon = { 573 icon = {
572 type: 'Image', 574 type: 'Image',
@@ -579,7 +581,7 @@ export class ActorModel extends Model {
579 581
580 if (this.bannerId) { 582 if (this.bannerId) {
581 const banner = (this as MActorAPChannel).Banner 583 const banner = (this as MActorAPChannel).Banner
582 const extension = extname(banner.filename) 584 const extension = getLowercaseExtension(banner.filename)
583 585
584 image = { 586 image = {
585 type: 'Image', 587 type: 'Image',
diff --git a/server/models/application/application.ts b/server/models/application/application.ts
index 21f8b1cbc..5531d134a 100644
--- a/server/models/application/application.ts
+++ b/server/models/application/application.ts
@@ -1,6 +1,7 @@
1import * as memoizee from 'memoizee'
1import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript' 2import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript'
3import { AttributesOnly } from '@shared/core-utils'
2import { AccountModel } from '../account/account' 4import { AccountModel } from '../account/account'
3import * as memoizee from 'memoizee'
4 5
5export const getServerActor = memoizee(async function () { 6export const getServerActor = memoizee(async function () {
6 const application = await ApplicationModel.load() 7 const application = await ApplicationModel.load()
@@ -24,7 +25,7 @@ export const getServerActor = memoizee(async function () {
24 tableName: 'application', 25 tableName: 'application',
25 timestamps: false 26 timestamps: false
26}) 27})
27export class ApplicationModel extends Model { 28export class ApplicationModel extends Model<Partial<AttributesOnly<ApplicationModel>>> {
28 29
29 @AllowNull(false) 30 @AllowNull(false)
30 @Default(0) 31 @Default(0)
diff --git a/server/models/oauth/oauth-client.ts b/server/models/oauth/oauth-client.ts
index 8dbc1c2f5..890954bdb 100644
--- a/server/models/oauth/oauth-client.ts
+++ b/server/models/oauth/oauth-client.ts
@@ -1,4 +1,5 @@
1import { AllowNull, Column, CreatedAt, DataType, HasMany, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, Column, CreatedAt, DataType, HasMany, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { OAuthTokenModel } from './oauth-token' 3import { OAuthTokenModel } from './oauth-token'
3 4
4@Table({ 5@Table({
@@ -14,7 +15,7 @@ import { OAuthTokenModel } from './oauth-token'
14 } 15 }
15 ] 16 ]
16}) 17})
17export class OAuthClientModel extends Model { 18export class OAuthClientModel extends Model<Partial<AttributesOnly<OAuthClientModel>>> {
18 19
19 @AllowNull(false) 20 @AllowNull(false)
20 @Column 21 @Column
diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts
index 27e643aa7..af4b0ec42 100644
--- a/server/models/oauth/oauth-token.ts
+++ b/server/models/oauth/oauth-token.ts
@@ -15,10 +15,11 @@ import {
15import { TokensCache } from '@server/lib/auth/tokens-cache' 15import { TokensCache } from '@server/lib/auth/tokens-cache'
16import { MUserAccountId } from '@server/types/models' 16import { MUserAccountId } from '@server/types/models'
17import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 17import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
18import { AttributesOnly } from '@shared/core-utils'
18import { logger } from '../../helpers/logger' 19import { logger } from '../../helpers/logger'
19import { AccountModel } from '../account/account' 20import { AccountModel } from '../account/account'
20import { UserModel } from '../account/user' 21import { ActorModel } from '../actor/actor'
21import { ActorModel } from '../activitypub/actor' 22import { UserModel } from '../user/user'
22import { OAuthClientModel } from './oauth-client' 23import { OAuthClientModel } from './oauth-client'
23 24
24export type OAuthTokenInfo = { 25export type OAuthTokenInfo = {
@@ -78,7 +79,7 @@ enum ScopeNames {
78 } 79 }
79 ] 80 ]
80}) 81})
81export class OAuthTokenModel extends Model { 82export class OAuthTokenModel extends Model<Partial<AttributesOnly<OAuthTokenModel>>> {
82 83
83 @AllowNull(false) 84 @AllowNull(false)
84 @Column 85 @Column
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 349dba513..ccda023e0 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -16,6 +16,7 @@ import {
16} from 'sequelize-typescript' 16} from 'sequelize-typescript'
17import { getServerActor } from '@server/models/application/application' 17import { getServerActor } from '@server/models/application/application'
18import { MActor, MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/types/models' 18import { MActor, MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/types/models'
19import { AttributesOnly } from '@shared/core-utils'
19import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model' 20import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model'
20import { 21import {
21 FileRedundancyInformation, 22 FileRedundancyInformation,
@@ -29,7 +30,7 @@ import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validato
29import { logger } from '../../helpers/logger' 30import { logger } from '../../helpers/logger'
30import { CONFIG } from '../../initializers/config' 31import { CONFIG } from '../../initializers/config'
31import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' 32import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
32import { ActorModel } from '../activitypub/actor' 33import { ActorModel } from '../actor/actor'
33import { ServerModel } from '../server/server' 34import { ServerModel } from '../server/server'
34import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' 35import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
35import { ScheduleVideoUpdateModel } from '../video/schedule-video-update' 36import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
@@ -79,12 +80,15 @@ export enum ScopeNames {
79 fields: [ 'actorId' ] 80 fields: [ 'actorId' ]
80 }, 81 },
81 { 82 {
83 fields: [ 'expiresOn' ]
84 },
85 {
82 fields: [ 'url' ], 86 fields: [ 'url' ],
83 unique: true 87 unique: true
84 } 88 }
85 ] 89 ]
86}) 90})
87export class VideoRedundancyModel extends Model { 91export class VideoRedundancyModel extends Model<Partial<AttributesOnly<VideoRedundancyModel>>> {
88 92
89 @CreatedAt 93 @CreatedAt
90 createdAt: Date 94 createdAt: Date
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 80c8a6be5..a8de64dd4 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -1,9 +1,8 @@
1import { FindAndCountOptions, json, QueryTypes } from 'sequelize' 1import { FindAndCountOptions, json, QueryTypes } from 'sequelize'
2import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MPlugin, MPluginFormattable } from '@server/types/models' 3import { MPlugin, MPluginFormattable } from '@server/types/models'
4import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model' 4import { AttributesOnly } from '@shared/core-utils'
5import { PluginType } from '../../../shared/models/plugins/plugin.type' 5import { PeerTubePlugin, PluginType, RegisterServerSettingOptions } from '../../../shared/models'
6import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model'
7import { 6import {
8 isPluginDescriptionValid, 7 isPluginDescriptionValid,
9 isPluginHomepage, 8 isPluginHomepage,
@@ -28,7 +27,7 @@ import { getSort, throwIfNotValid } from '../utils'
28 } 27 }
29 ] 28 ]
30}) 29})
31export class PluginModel extends Model { 30export class PluginModel extends Model<Partial<AttributesOnly<PluginModel>>> {
32 31
33 @AllowNull(false) 32 @AllowNull(false)
34 @Is('PluginName', value => throwIfNotValid(value, isPluginNameValid, 'name')) 33 @Is('PluginName', value => throwIfNotValid(value, isPluginNameValid, 'name'))
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 4dc236537..b3579d589 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -1,6 +1,7 @@
1import { Op } from 'sequelize' 1import { Op } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models' 3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { ServerBlock } from '@shared/models' 5import { ServerBlock } from '@shared/models'
5import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
6import { getSort, searchAttribute } from '../utils' 7import { getSort, searchAttribute } from '../utils'
@@ -42,7 +43,7 @@ enum ScopeNames {
42 } 43 }
43 ] 44 ]
44}) 45})
45export class ServerBlocklistModel extends Model { 46export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlocklistModel>>> {
46 47
47 @CreatedAt 48 @CreatedAt
48 createdAt: Date 49 createdAt: Date
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index 0e58beeaf..0d3c092e0 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -1,7 +1,9 @@
1import { Transaction } from 'sequelize'
1import { AllowNull, Column, CreatedAt, Default, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, Column, CreatedAt, Default, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { MServer, MServerFormattable } from '@server/types/models/server' 3import { MServer, MServerFormattable } from '@server/types/models/server'
4import { AttributesOnly } from '@shared/core-utils'
3import { isHostValid } from '../../helpers/custom-validators/servers' 5import { isHostValid } from '../../helpers/custom-validators/servers'
4import { ActorModel } from '../activitypub/actor' 6import { ActorModel } from '../actor/actor'
5import { throwIfNotValid } from '../utils' 7import { throwIfNotValid } from '../utils'
6import { ServerBlocklistModel } from './server-blocklist' 8import { ServerBlocklistModel } from './server-blocklist'
7 9
@@ -14,7 +16,7 @@ import { ServerBlocklistModel } from './server-blocklist'
14 } 16 }
15 ] 17 ]
16}) 18})
17export class ServerModel extends Model { 19export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
18 20
19 @AllowNull(false) 21 @AllowNull(false)
20 @Is('Host', value => throwIfNotValid(value, isHostValid, 'valid host')) 22 @Is('Host', value => throwIfNotValid(value, isHostValid, 'valid host'))
@@ -50,11 +52,12 @@ export class ServerModel extends Model {
50 }) 52 })
51 BlockedByAccounts: ServerBlocklistModel[] 53 BlockedByAccounts: ServerBlocklistModel[]
52 54
53 static load (id: number): Promise<MServer> { 55 static load (id: number, transaction?: Transaction): Promise<MServer> {
54 const query = { 56 const query = {
55 where: { 57 where: {
56 id 58 id
57 } 59 },
60 transaction
58 } 61 }
59 62
60 return ServerModel.findOne(query) 63 return ServerModel.findOne(query)
diff --git a/server/models/server/tracker.ts b/server/models/server/tracker.ts
index 97520f92d..c09fdd64b 100644
--- a/server/models/server/tracker.ts
+++ b/server/models/server/tracker.ts
@@ -1,6 +1,7 @@
1import { AllowNull, BelongsToMany, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsToMany, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
3import { MTracker } from '@server/types/models/server/tracker' 3import { MTracker } from '@server/types/models/server/tracker'
4import { AttributesOnly } from '@shared/core-utils'
4import { VideoModel } from '../video/video' 5import { VideoModel } from '../video/video'
5import { VideoTrackerModel } from './video-tracker' 6import { VideoTrackerModel } from './video-tracker'
6 7
@@ -13,7 +14,7 @@ import { VideoTrackerModel } from './video-tracker'
13 } 14 }
14 ] 15 ]
15}) 16})
16export class TrackerModel extends Model { 17export class TrackerModel extends Model<Partial<AttributesOnly<TrackerModel>>> {
17 18
18 @AllowNull(false) 19 @AllowNull(false)
19 @Column 20 @Column
diff --git a/server/models/server/video-tracker.ts b/server/models/server/video-tracker.ts
index 367bf0117..c49fbd1c6 100644
--- a/server/models/server/video-tracker.ts
+++ b/server/models/server/video-tracker.ts
@@ -1,4 +1,5 @@
1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { VideoModel } from '../video/video' 3import { VideoModel } from '../video/video'
3import { TrackerModel } from './tracker' 4import { TrackerModel } from './tracker'
4 5
@@ -13,7 +14,7 @@ import { TrackerModel } from './tracker'
13 } 14 }
14 ] 15 ]
15}) 16})
16export class VideoTrackerModel extends Model { 17export class VideoTrackerModel extends Model<Partial<AttributesOnly<VideoTrackerModel>>> {
17 @CreatedAt 18 @CreatedAt
18 createdAt: Date 19 createdAt: Date
19 20
diff --git a/server/models/account/user-notification-setting.ts b/server/models/user/user-notification-setting.ts
index 138051528..bee7d7851 100644
--- a/server/models/account/user-notification-setting.ts
+++ b/server/models/user/user-notification-setting.ts
@@ -14,6 +14,7 @@ import {
14} from 'sequelize-typescript' 14} from 'sequelize-typescript'
15import { TokensCache } from '@server/lib/auth/tokens-cache' 15import { TokensCache } from '@server/lib/auth/tokens-cache'
16import { MNotificationSettingFormattable } from '@server/types/models' 16import { MNotificationSettingFormattable } from '@server/types/models'
17import { AttributesOnly } from '@shared/core-utils'
17import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model' 18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
18import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' 19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
19import { throwIfNotValid } from '../utils' 20import { throwIfNotValid } from '../utils'
@@ -28,7 +29,7 @@ import { UserModel } from './user'
28 } 29 }
29 ] 30 ]
30}) 31})
31export class UserNotificationSettingModel extends Model { 32export class UserNotificationSettingModel extends Model<Partial<AttributesOnly<UserNotificationSettingModel>>> {
32 33
33 @AllowNull(false) 34 @AllowNull(false)
34 @Default(null) 35 @Default(null)
diff --git a/server/models/account/user-notification.ts b/server/models/user/user-notification.ts
index 805095002..a7f84e9ca 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -1,14 +1,17 @@
1import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize' 1import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' 3import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
4import { AttributesOnly } from '@shared/core-utils'
4import { UserNotification, UserNotificationType } from '../../../shared' 5import { UserNotification, UserNotificationType } from '../../../shared'
5import { isBooleanValid } from '../../helpers/custom-validators/misc' 6import { isBooleanValid } from '../../helpers/custom-validators/misc'
6import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' 7import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
7import { AbuseModel } from '../abuse/abuse' 8import { AbuseModel } from '../abuse/abuse'
8import { VideoAbuseModel } from '../abuse/video-abuse' 9import { VideoAbuseModel } from '../abuse/video-abuse'
9import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 10import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
10import { ActorModel } from '../activitypub/actor' 11import { AccountModel } from '../account/account'
11import { ActorFollowModel } from '../activitypub/actor-follow' 12import { ActorModel } from '../actor/actor'
13import { ActorFollowModel } from '../actor/actor-follow'
14import { ActorImageModel } from '../actor/actor-image'
12import { ApplicationModel } from '../application/application' 15import { ApplicationModel } from '../application/application'
13import { PluginModel } from '../server/plugin' 16import { PluginModel } from '../server/plugin'
14import { ServerModel } from '../server/server' 17import { ServerModel } from '../server/server'
@@ -18,8 +21,6 @@ import { VideoBlacklistModel } from '../video/video-blacklist'
18import { VideoChannelModel } from '../video/video-channel' 21import { VideoChannelModel } from '../video/video-channel'
19import { VideoCommentModel } from '../video/video-comment' 22import { VideoCommentModel } from '../video/video-comment'
20import { VideoImportModel } from '../video/video-import' 23import { VideoImportModel } from '../video/video-import'
21import { AccountModel } from './account'
22import { ActorImageModel } from './actor-image'
23import { UserModel } from './user' 24import { UserModel } from './user'
24 25
25enum ScopeNames { 26enum ScopeNames {
@@ -286,7 +287,7 @@ function buildAccountInclude (required: boolean, withActor = false) {
286 } 287 }
287 ] as (ModelIndexesOptions & { where?: WhereOptions })[] 288 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
288}) 289})
289export class UserNotificationModel extends Model { 290export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
290 291
291 @AllowNull(false) 292 @AllowNull(false)
292 @Default(null) 293 @Default(null)
diff --git a/server/models/account/user-video-history.ts b/server/models/user/user-video-history.ts
index 6be1d65ea..e3dc4a062 100644
--- a/server/models/account/user-video-history.ts
+++ b/server/models/user/user-video-history.ts
@@ -1,8 +1,9 @@
1import { DestroyOptions, Op, Transaction } from 'sequelize'
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MUserAccountId, MUserId } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
2import { VideoModel } from '../video/video' 5import { VideoModel } from '../video/video'
3import { UserModel } from './user' 6import { UserModel } from './user'
4import { DestroyOptions, Op, Transaction } from 'sequelize'
5import { MUserAccountId, MUserId } from '@server/types/models'
6 7
7@Table({ 8@Table({
8 tableName: 'userVideoHistory', 9 tableName: 'userVideoHistory',
@@ -19,7 +20,7 @@ import { MUserAccountId, MUserId } from '@server/types/models'
19 } 20 }
20 ] 21 ]
21}) 22})
22export class UserVideoHistoryModel extends Model { 23export class UserVideoHistoryModel extends Model<Partial<AttributesOnly<UserVideoHistoryModel>>> {
23 @CreatedAt 24 @CreatedAt
24 createdAt: Date 25 createdAt: Date
25 26
diff --git a/server/models/account/user.ts b/server/models/user/user.ts
index 513455773..20696b1f4 100644
--- a/server/models/account/user.ts
+++ b/server/models/user/user.ts
@@ -31,6 +31,7 @@ import {
31 MUserWithNotificationSetting, 31 MUserWithNotificationSetting,
32 MVideoWithRights 32 MVideoWithRights
33} from '@server/types/models' 33} from '@server/types/models'
34import { AttributesOnly } from '@shared/core-utils'
34import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users' 35import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
35import { AbuseState, MyUser, UserRight, VideoPlaylistType, VideoPrivacy } from '../../../shared/models' 36import { AbuseState, MyUser, UserRight, VideoPlaylistType, VideoPrivacy } from '../../../shared/models'
36import { User, UserRole } from '../../../shared/models/users' 37import { User, UserRole } from '../../../shared/models/users'
@@ -60,8 +61,10 @@ import {
60import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' 61import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
61import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants' 62import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants'
62import { getThemeOrDefault } from '../../lib/plugins/theme-utils' 63import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
63import { ActorModel } from '../activitypub/actor' 64import { AccountModel } from '../account/account'
64import { ActorFollowModel } from '../activitypub/actor-follow' 65import { ActorModel } from '../actor/actor'
66import { ActorFollowModel } from '../actor/actor-follow'
67import { ActorImageModel } from '../actor/actor-image'
65import { OAuthTokenModel } from '../oauth/oauth-token' 68import { OAuthTokenModel } from '../oauth/oauth-token'
66import { getSort, throwIfNotValid } from '../utils' 69import { getSort, throwIfNotValid } from '../utils'
67import { VideoModel } from '../video/video' 70import { VideoModel } from '../video/video'
@@ -69,9 +72,7 @@ import { VideoChannelModel } from '../video/video-channel'
69import { VideoImportModel } from '../video/video-import' 72import { VideoImportModel } from '../video/video-import'
70import { VideoLiveModel } from '../video/video-live' 73import { VideoLiveModel } from '../video/video-live'
71import { VideoPlaylistModel } from '../video/video-playlist' 74import { VideoPlaylistModel } from '../video/video-playlist'
72import { AccountModel } from './account'
73import { UserNotificationSettingModel } from './user-notification-setting' 75import { UserNotificationSettingModel } from './user-notification-setting'
74import { ActorImageModel } from './actor-image'
75 76
76enum ScopeNames { 77enum ScopeNames {
77 FOR_ME_API = 'FOR_ME_API', 78 FOR_ME_API = 'FOR_ME_API',
@@ -233,7 +234,7 @@ enum ScopeNames {
233 } 234 }
234 ] 235 ]
235}) 236})
236export class UserModel extends Model { 237export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
237 238
238 @AllowNull(true) 239 @AllowNull(true)
239 @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password', true)) 240 @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password', true))
diff --git a/server/models/utils.ts b/server/models/utils.ts
index ec51c66bf..83b2b8f03 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -1,5 +1,4 @@
1import { literal, Op, OrderItem } from 'sequelize' 1import { literal, Op, OrderItem, Sequelize } from 'sequelize'
2import { Model, Sequelize } from 'sequelize-typescript'
3import { Col } from 'sequelize/types/lib/utils' 2import { Col } from 'sequelize/types/lib/utils'
4import validator from 'validator' 3import validator from 'validator'
5 4
@@ -103,6 +102,10 @@ function getFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]):
103} 102}
104 103
105function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) { 104function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
105 if (!model.createdAt || !model.updatedAt) {
106 throw new Error('Miss createdAt & updatedAt attribuets to model')
107 }
108
106 const now = Date.now() 109 const now = Date.now()
107 const createdAtTime = model.createdAt.getTime() 110 const createdAtTime = model.createdAt.getTime()
108 const updatedAtTime = model.updatedAt.getTime() 111 const updatedAtTime = model.updatedAt.getTime()
@@ -195,11 +198,11 @@ function parseAggregateResult (result: any) {
195 return total 198 return total
196} 199}
197 200
198const createSafeIn = (model: typeof Model, stringArr: (string | number)[]) => { 201function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
199 return stringArr.map(t => { 202 return stringArr.map(t => {
200 return t === null 203 return t === null
201 ? null 204 ? null
202 : model.sequelize.escape('' + t) 205 : sequelize.escape('' + t)
203 }).join(', ') 206 }).join(', ')
204} 207}
205 208
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
index 551cb2842..6b1e59063 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/formatter/video-format-utils.ts
@@ -1,17 +1,26 @@
1import { uuidToShort } from '@server/helpers/uuid'
1import { generateMagnetUri } from '@server/helpers/webtorrent' 2import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { getLocalVideoFileMetadataUrl } from '@server/lib/video-paths' 3import { getLocalVideoFileMetadataUrl } from '@server/lib/video-paths'
3import { VideoFile } from '@shared/models/videos/video-file.model' 4import { VideoFile } from '@shared/models/videos/video-file.model'
4import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../shared/models/activitypub/objects' 5import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../../shared/models/activitypub/objects'
5import { Video, VideoDetails } from '../../../shared/models/videos' 6import { Video, VideoDetails } from '../../../../shared/models/videos'
6import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' 7import { VideoStreamingPlaylist } from '../../../../shared/models/videos/video-streaming-playlist.model'
7import { isArray } from '../../helpers/custom-validators/misc' 8import { isArray } from '../../../helpers/custom-validators/misc'
8import { MIMETYPES, WEBSERVER } from '../../initializers/constants' 9import {
10 MIMETYPES,
11 VIDEO_CATEGORIES,
12 VIDEO_LANGUAGES,
13 VIDEO_LICENCES,
14 VIDEO_PRIVACIES,
15 VIDEO_STATES,
16 WEBSERVER
17} from '../../../initializers/constants'
9import { 18import {
10 getLocalVideoCommentsActivityPubUrl, 19 getLocalVideoCommentsActivityPubUrl,
11 getLocalVideoDislikesActivityPubUrl, 20 getLocalVideoDislikesActivityPubUrl,
12 getLocalVideoLikesActivityPubUrl, 21 getLocalVideoLikesActivityPubUrl,
13 getLocalVideoSharesActivityPubUrl 22 getLocalVideoSharesActivityPubUrl
14} from '../../lib/activitypub/url' 23} from '../../../lib/activitypub/url'
15import { 24import {
16 MStreamingPlaylistRedundanciesOpt, 25 MStreamingPlaylistRedundanciesOpt,
17 MVideo, 26 MVideo,
@@ -19,10 +28,9 @@ import {
19 MVideoFile, 28 MVideoFile,
20 MVideoFormattable, 29 MVideoFormattable,
21 MVideoFormattableDetails 30 MVideoFormattableDetails
22} from '../../types/models' 31} from '../../../types/models'
23import { MVideoFileRedundanciesOpt } from '../../types/models/video/video-file' 32import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
24import { VideoModel } from './video' 33import { VideoCaptionModel } from '../video-caption'
25import { VideoCaptionModel } from './video-caption'
26 34
27export type VideoFormattingJSONOptions = { 35export type VideoFormattingJSONOptions = {
28 completeDescription?: boolean 36 completeDescription?: boolean
@@ -40,22 +48,24 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
40 const videoObject: Video = { 48 const videoObject: Video = {
41 id: video.id, 49 id: video.id,
42 uuid: video.uuid, 50 uuid: video.uuid,
51 shortUUID: uuidToShort(video.uuid),
52
43 name: video.name, 53 name: video.name,
44 category: { 54 category: {
45 id: video.category, 55 id: video.category,
46 label: VideoModel.getCategoryLabel(video.category) 56 label: getCategoryLabel(video.category)
47 }, 57 },
48 licence: { 58 licence: {
49 id: video.licence, 59 id: video.licence,
50 label: VideoModel.getLicenceLabel(video.licence) 60 label: getLicenceLabel(video.licence)
51 }, 61 },
52 language: { 62 language: {
53 id: video.language, 63 id: video.language,
54 label: VideoModel.getLanguageLabel(video.language) 64 label: getLanguageLabel(video.language)
55 }, 65 },
56 privacy: { 66 privacy: {
57 id: video.privacy, 67 id: video.privacy,
58 label: VideoModel.getPrivacyLabel(video.privacy) 68 label: getPrivacyLabel(video.privacy)
59 }, 69 },
60 nsfw: video.nsfw, 70 nsfw: video.nsfw,
61 71
@@ -93,7 +103,7 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
93 if (options.additionalAttributes.state === true) { 103 if (options.additionalAttributes.state === true) {
94 videoObject.state = { 104 videoObject.state = {
95 id: video.state, 105 id: video.state,
96 label: VideoModel.getStateLabel(video.state) 106 label: getStateLabel(video.state)
97 } 107 }
98 } 108 }
99 109
@@ -140,7 +150,7 @@ function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): Vid
140 waitTranscoding: video.waitTranscoding, 150 waitTranscoding: video.waitTranscoding,
141 state: { 151 state: {
142 id: video.state, 152 id: video.state,
143 label: VideoModel.getStateLabel(video.state) 153 label: getStateLabel(video.state)
144 }, 154 },
145 155
146 trackerUrls: video.getTrackerUrls(), 156 trackerUrls: video.getTrackerUrls(),
@@ -202,7 +212,7 @@ function videoFilesModelToFormattedJSON (
202 return { 212 return {
203 resolution: { 213 resolution: {
204 id: videoFile.resolution, 214 id: videoFile.resolution,
205 label: videoFile.resolution + 'p' 215 label: videoFile.resolution === 0 ? 'Audio' : `${videoFile.resolution}p`
206 }, 216 },
207 217
208 magnetUri: includeMagnet && videoFile.hasTorrent() 218 magnetUri: includeMagnet && videoFile.hasTorrent()
@@ -283,7 +293,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
283 if (video.language) { 293 if (video.language) {
284 language = { 294 language = {
285 identifier: video.language, 295 identifier: video.language,
286 name: VideoModel.getLanguageLabel(video.language) 296 name: getLanguageLabel(video.language)
287 } 297 }
288 } 298 }
289 299
@@ -291,7 +301,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
291 if (video.category) { 301 if (video.category) {
292 category = { 302 category = {
293 identifier: video.category + '', 303 identifier: video.category + '',
294 name: VideoModel.getCategoryLabel(video.category) 304 name: getCategoryLabel(video.category)
295 } 305 }
296 } 306 }
297 307
@@ -299,7 +309,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
299 if (video.licence) { 309 if (video.licence) {
300 licence = { 310 licence = {
301 identifier: video.licence + '', 311 identifier: video.licence + '',
302 name: VideoModel.getLicenceLabel(video.licence) 312 name: getLicenceLabel(video.licence)
303 } 313 }
304 } 314 }
305 315
@@ -425,10 +435,36 @@ function getActivityStreamDuration (duration: number) {
425 return 'PT' + duration + 'S' 435 return 'PT' + duration + 'S'
426} 436}
427 437
438function getCategoryLabel (id: number) {
439 return VIDEO_CATEGORIES[id] || 'Misc'
440}
441
442function getLicenceLabel (id: number) {
443 return VIDEO_LICENCES[id] || 'Unknown'
444}
445
446function getLanguageLabel (id: string) {
447 return VIDEO_LANGUAGES[id] || 'Unknown'
448}
449
450function getPrivacyLabel (id: number) {
451 return VIDEO_PRIVACIES[id] || 'Unknown'
452}
453
454function getStateLabel (id: number) {
455 return VIDEO_STATES[id] || 'Unknown'
456}
457
428export { 458export {
429 videoModelToFormattedJSON, 459 videoModelToFormattedJSON,
430 videoModelToFormattedDetailsJSON, 460 videoModelToFormattedDetailsJSON,
431 videoFilesModelToFormattedJSON, 461 videoFilesModelToFormattedJSON,
432 videoModelToActivityPubObject, 462 videoModelToActivityPubObject,
433 getActivityStreamDuration 463 getActivityStreamDuration,
464
465 getCategoryLabel,
466 getLicenceLabel,
467 getLanguageLabel,
468 getPrivacyLabel,
469 getStateLabel
434} 470}
diff --git a/server/models/video/schedule-video-update.ts b/server/models/video/schedule-video-update.ts
index 22b08e91a..d462c20c7 100644
--- a/server/models/video/schedule-video-update.ts
+++ b/server/models/video/schedule-video-update.ts
@@ -1,8 +1,9 @@
1import { Op, Transaction } from 'sequelize'
1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { ScopeNames as VideoScopeNames, VideoModel } from './video' 3import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdate } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
3import { VideoPrivacy } from '../../../shared/models/videos' 5import { VideoPrivacy } from '../../../shared/models/videos'
4import { Op, Transaction } from 'sequelize' 6import { VideoModel } from './video'
5import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdateVideoAll } from '@server/types/models'
6 7
7@Table({ 8@Table({
8 tableName: 'scheduleVideoUpdate', 9 tableName: 'scheduleVideoUpdate',
@@ -16,7 +17,7 @@ import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdateVideoAll } from '@
16 } 17 }
17 ] 18 ]
18}) 19})
19export class ScheduleVideoUpdateModel extends Model { 20export class ScheduleVideoUpdateModel extends Model<Partial<AttributesOnly<ScheduleVideoUpdateModel>>> {
20 21
21 @AllowNull(false) 22 @AllowNull(false)
22 @Default(null) 23 @Default(null)
@@ -61,31 +62,17 @@ export class ScheduleVideoUpdateModel extends Model {
61 .then(res => !!res) 62 .then(res => !!res)
62 } 63 }
63 64
64 static listVideosToUpdate (t: Transaction) { 65 static listVideosToUpdate (transaction?: Transaction) {
65 const query = { 66 const query = {
66 where: { 67 where: {
67 updateAt: { 68 updateAt: {
68 [Op.lte]: new Date() 69 [Op.lte]: new Date()
69 } 70 }
70 }, 71 },
71 include: [ 72 transaction
72 {
73 model: VideoModel.scope(
74 [
75 VideoScopeNames.WITH_WEBTORRENT_FILES,
76 VideoScopeNames.WITH_STREAMING_PLAYLISTS,
77 VideoScopeNames.WITH_ACCOUNT_DETAILS,
78 VideoScopeNames.WITH_BLACKLISTED,
79 VideoScopeNames.WITH_THUMBNAILS,
80 VideoScopeNames.WITH_TAGS
81 ]
82 )
83 }
84 ],
85 transaction: t
86 } 73 }
87 74
88 return ScheduleVideoUpdateModel.findAll<MScheduleVideoUpdateVideoAll>(query) 75 return ScheduleVideoUpdateModel.findAll<MScheduleVideoUpdate>(query)
89 } 76 }
90 77
91 static deleteByVideoId (videoId: number, t: Transaction) { 78 static deleteByVideoId (videoId: number, t: Transaction) {
diff --git a/server/models/video/sql/shared/abstract-videos-model-query-builder.ts b/server/models/video/sql/shared/abstract-videos-model-query-builder.ts
new file mode 100644
index 000000000..0d7e64574
--- /dev/null
+++ b/server/models/video/sql/shared/abstract-videos-model-query-builder.ts
@@ -0,0 +1,300 @@
1import validator from 'validator'
2import { AbstractVideosQueryBuilder } from './abstract-videos-query-builder'
3import { VideoTables } from './video-tables'
4
5/**
6 *
7 * Abstract builder to create SQL query and fetch video models
8 *
9 */
10
11export class AbstractVideosModelQueryBuilder extends AbstractVideosQueryBuilder {
12 protected attributes: { [key: string]: string } = {}
13
14 protected joins = ''
15 protected where: string
16
17 protected tables: VideoTables
18
19 constructor (protected readonly mode: 'list' | 'get') {
20 super()
21
22 this.tables = new VideoTables(this.mode)
23 }
24
25 protected buildSelect () {
26 return 'SELECT ' + Object.keys(this.attributes).map(key => {
27 const value = this.attributes[key]
28 if (value) return `${key} AS ${value}`
29
30 return key
31 }).join(', ')
32 }
33
34 protected includeChannels () {
35 this.addJoin('INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"')
36 this.addJoin('INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"')
37
38 this.addJoin(
39 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"'
40 )
41
42 this.addJoin(
43 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatar" ' +
44 'ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"'
45 )
46
47 this.attributes = {
48 ...this.attributes,
49
50 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
51 ...this.buildActorInclude('VideoChannel->Actor'),
52 ...this.buildAvatarInclude('VideoChannel->Actor->Avatar'),
53 ...this.buildServerInclude('VideoChannel->Actor->Server')
54 }
55 }
56
57 protected includeAccounts () {
58 this.addJoin('INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"')
59 this.addJoin(
60 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"'
61 )
62
63 this.addJoin(
64 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' +
65 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"'
66 )
67
68 this.addJoin(
69 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatar" ' +
70 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"'
71 )
72
73 this.attributes = {
74 ...this.attributes,
75
76 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()),
77 ...this.buildActorInclude('VideoChannel->Account->Actor'),
78 ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatar'),
79 ...this.buildServerInclude('VideoChannel->Account->Actor->Server')
80 }
81 }
82
83 protected includeOwnerUser () {
84 this.addJoin('INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"')
85 this.addJoin('INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"')
86
87 this.attributes = {
88 ...this.attributes,
89
90 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
91 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getUserAccountAttributes())
92 }
93 }
94
95 protected includeThumbnails () {
96 this.addJoin('LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"')
97
98 this.attributes = {
99 ...this.attributes,
100
101 ...this.buildAttributesObject('Thumbnails', this.tables.getThumbnailAttributes())
102 }
103 }
104
105 protected includeWebtorrentFiles () {
106 this.addJoin('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"')
107
108 this.attributes = {
109 ...this.attributes,
110
111 ...this.buildAttributesObject('VideoFiles', this.tables.getFileAttributes())
112 }
113 }
114
115 protected includeStreamingPlaylistFiles () {
116 this.addJoin(
117 'LEFT JOIN "videoStreamingPlaylist" AS "VideoStreamingPlaylists" ON "VideoStreamingPlaylists"."videoId" = "video"."id"'
118 )
119
120 this.addJoin(
121 'LEFT JOIN "videoFile" AS "VideoStreamingPlaylists->VideoFiles" ' +
122 'ON "VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId" = "VideoStreamingPlaylists"."id"'
123 )
124
125 this.attributes = {
126 ...this.attributes,
127
128 ...this.buildAttributesObject('VideoStreamingPlaylists', this.tables.getStreamingPlaylistAttributes()),
129 ...this.buildAttributesObject('VideoStreamingPlaylists->VideoFiles', this.tables.getFileAttributes())
130 }
131 }
132
133 protected includeUserHistory (userId: number) {
134 this.addJoin(
135 'LEFT OUTER JOIN "userVideoHistory" ' +
136 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId'
137 )
138
139 this.replacements.userVideoHistoryId = userId
140
141 this.attributes = {
142 ...this.attributes,
143
144 ...this.buildAttributesObject('userVideoHistory', this.tables.getUserHistoryAttributes())
145 }
146 }
147
148 protected includePlaylist (playlistId: number) {
149 this.addJoin(
150 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
151 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
152 )
153
154 this.replacements.videoPlaylistId = playlistId
155
156 this.attributes = {
157 ...this.attributes,
158
159 ...this.buildAttributesObject('VideoPlaylistElement', this.tables.getPlaylistAttributes())
160 }
161 }
162
163 protected includeTags () {
164 this.addJoin(
165 'LEFT OUTER JOIN (' +
166 '"videoTag" AS "Tags->VideoTagModel" INNER JOIN "tag" AS "Tags" ON "Tags"."id" = "Tags->VideoTagModel"."tagId"' +
167 ') ' +
168 'ON "video"."id" = "Tags->VideoTagModel"."videoId"'
169 )
170
171 this.attributes = {
172 ...this.attributes,
173
174 ...this.buildAttributesObject('Tags', this.tables.getTagAttributes()),
175 ...this.buildAttributesObject('Tags->VideoTagModel', this.tables.getVideoTagAttributes())
176 }
177 }
178
179 protected includeBlacklisted () {
180 this.addJoin(
181 'LEFT OUTER JOIN "videoBlacklist" AS "VideoBlacklist" ON "video"."id" = "VideoBlacklist"."videoId"'
182 )
183
184 this.attributes = {
185 ...this.attributes,
186
187 ...this.buildAttributesObject('VideoBlacklist', this.tables.getBlacklistedAttributes())
188 }
189 }
190
191 protected includeScheduleUpdate () {
192 this.addJoin(
193 'LEFT OUTER JOIN "scheduleVideoUpdate" AS "ScheduleVideoUpdate" ON "video"."id" = "ScheduleVideoUpdate"."videoId"'
194 )
195
196 this.attributes = {
197 ...this.attributes,
198
199 ...this.buildAttributesObject('ScheduleVideoUpdate', this.tables.getScheduleUpdateAttributes())
200 }
201 }
202
203 protected includeLive () {
204 this.addJoin(
205 'LEFT OUTER JOIN "videoLive" AS "VideoLive" ON "video"."id" = "VideoLive"."videoId"'
206 )
207
208 this.attributes = {
209 ...this.attributes,
210
211 ...this.buildAttributesObject('VideoLive', this.tables.getLiveAttributes())
212 }
213 }
214
215 protected includeTrackers () {
216 this.addJoin(
217 'LEFT OUTER JOIN (' +
218 '"videoTracker" AS "Trackers->VideoTrackerModel" ' +
219 'INNER JOIN "tracker" AS "Trackers" ON "Trackers"."id" = "Trackers->VideoTrackerModel"."trackerId"' +
220 ') ON "video"."id" = "Trackers->VideoTrackerModel"."videoId"'
221 )
222
223 this.attributes = {
224 ...this.attributes,
225
226 ...this.buildAttributesObject('Trackers', this.tables.getTrackerAttributes()),
227 ...this.buildAttributesObject('Trackers->VideoTrackerModel', this.tables.getVideoTrackerAttributes())
228 }
229 }
230
231 protected includeWebTorrentRedundancies () {
232 this.addJoin(
233 'LEFT OUTER JOIN "videoRedundancy" AS "VideoFiles->RedundancyVideos" ON ' +
234 '"VideoFiles"."id" = "VideoFiles->RedundancyVideos"."videoFileId"'
235 )
236
237 this.attributes = {
238 ...this.attributes,
239
240 ...this.buildAttributesObject('VideoFiles->RedundancyVideos', this.tables.getRedundancyAttributes())
241 }
242 }
243
244 protected includeStreamingPlaylistRedundancies () {
245 this.addJoin(
246 'LEFT OUTER JOIN "videoRedundancy" AS "VideoStreamingPlaylists->RedundancyVideos" ' +
247 'ON "VideoStreamingPlaylists"."id" = "VideoStreamingPlaylists->RedundancyVideos"."videoStreamingPlaylistId"'
248 )
249
250 this.attributes = {
251 ...this.attributes,
252
253 ...this.buildAttributesObject('VideoStreamingPlaylists->RedundancyVideos', this.tables.getRedundancyAttributes())
254 }
255 }
256
257 protected buildActorInclude (prefixKey: string) {
258 return this.buildAttributesObject(prefixKey, this.tables.getActorAttributes())
259 }
260
261 protected buildAvatarInclude (prefixKey: string) {
262 return this.buildAttributesObject(prefixKey, this.tables.getAvatarAttributes())
263 }
264
265 protected buildServerInclude (prefixKey: string) {
266 return this.buildAttributesObject(prefixKey, this.tables.getServerAttributes())
267 }
268
269 protected buildAttributesObject (prefixKey: string, attributeKeys: string[]) {
270 const result: { [id: string]: string} = {}
271
272 const prefixValue = prefixKey.replace(/->/g, '.')
273
274 for (const attribute of attributeKeys) {
275 result[`"${prefixKey}"."${attribute}"`] = `"${prefixValue}.${attribute}"`
276 }
277
278 return result
279 }
280
281 protected whereId (options: { id?: string | number, url?: string }) {
282 if (options.url) {
283 this.where = 'WHERE "video"."url" = :videoUrl'
284 this.replacements.videoUrl = options.url
285 return
286 }
287
288 if (validator.isInt('' + options.id)) {
289 this.where = 'WHERE "video".id = :videoId'
290 } else {
291 this.where = 'WHERE uuid = :videoId'
292 }
293
294 this.replacements.videoId = options.id
295 }
296
297 protected addJoin (join: string) {
298 this.joins += join + ' '
299 }
300}
diff --git a/server/models/video/sql/shared/abstract-videos-query-builder.ts b/server/models/video/sql/shared/abstract-videos-query-builder.ts
new file mode 100644
index 000000000..09776bcb0
--- /dev/null
+++ b/server/models/video/sql/shared/abstract-videos-query-builder.ts
@@ -0,0 +1,26 @@
1import { QueryTypes, Sequelize, Transaction } from 'sequelize'
2
3/**
4 *
5 * Abstact builder to run video SQL queries
6 *
7 */
8
9export class AbstractVideosQueryBuilder {
10 protected sequelize: Sequelize
11
12 protected query: string
13 protected replacements: any = {}
14
15 protected runQuery (options: { transaction?: Transaction, logging?: boolean } = {}) {
16 const queryOptions = {
17 transaction: options.transaction,
18 logging: options.logging,
19 replacements: this.replacements,
20 type: QueryTypes.SELECT as QueryTypes.SELECT,
21 nest: false
22 }
23
24 return this.sequelize.query<any>(this.query, queryOptions)
25 }
26}
diff --git a/server/models/video/sql/shared/video-file-query-builder.ts b/server/models/video/sql/shared/video-file-query-builder.ts
new file mode 100644
index 000000000..6b15c3b69
--- /dev/null
+++ b/server/models/video/sql/shared/video-file-query-builder.ts
@@ -0,0 +1,69 @@
1import { Sequelize } from 'sequelize'
2import { BuildVideoGetQueryOptions } from '../video-model-get-query-builder'
3import { AbstractVideosModelQueryBuilder } from './abstract-videos-model-query-builder'
4
5/**
6 *
7 * Fetch files (webtorrent and streaming playlist) according to a video
8 *
9 */
10
11export class VideoFileQueryBuilder extends AbstractVideosModelQueryBuilder {
12 protected attributes: { [key: string]: string }
13
14 constructor (protected readonly sequelize: Sequelize) {
15 super('get')
16 }
17
18 queryWebTorrentVideos (options: BuildVideoGetQueryOptions) {
19 this.buildWebtorrentFilesQuery(options)
20
21 return this.runQuery(options)
22 }
23
24 queryStreamingPlaylistVideos (options: BuildVideoGetQueryOptions) {
25 this.buildVideoStreamingPlaylistFilesQuery(options)
26
27 return this.runQuery(options)
28 }
29
30 private buildWebtorrentFilesQuery (options: BuildVideoGetQueryOptions) {
31 this.attributes = {
32 '"video"."id"': ''
33 }
34
35 this.includeWebtorrentFiles()
36
37 if (this.shouldIncludeRedundancies(options)) {
38 this.includeWebTorrentRedundancies()
39 }
40
41 this.whereId(options)
42
43 this.query = this.buildQuery()
44 }
45
46 private buildVideoStreamingPlaylistFilesQuery (options: BuildVideoGetQueryOptions) {
47 this.attributes = {
48 '"video"."id"': ''
49 }
50
51 this.includeStreamingPlaylistFiles()
52
53 if (this.shouldIncludeRedundancies(options)) {
54 this.includeStreamingPlaylistRedundancies()
55 }
56
57 this.whereId(options)
58
59 this.query = this.buildQuery()
60 }
61
62 private buildQuery () {
63 return `${this.buildSelect()} FROM "video" ${this.joins} ${this.where}`
64 }
65
66 private shouldIncludeRedundancies (options: BuildVideoGetQueryOptions) {
67 return options.type === 'api'
68 }
69}
diff --git a/server/models/video/sql/shared/video-model-builder.ts b/server/models/video/sql/shared/video-model-builder.ts
new file mode 100644
index 000000000..e7e2aa1ca
--- /dev/null
+++ b/server/models/video/sql/shared/video-model-builder.ts
@@ -0,0 +1,333 @@
1
2import { AccountModel } from '@server/models/account/account'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorImageModel } from '@server/models/actor/actor-image'
5import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
6import { ServerModel } from '@server/models/server/server'
7import { TrackerModel } from '@server/models/server/tracker'
8import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
9import { ScheduleVideoUpdateModel } from '../../schedule-video-update'
10import { TagModel } from '../../tag'
11import { ThumbnailModel } from '../../thumbnail'
12import { VideoModel } from '../../video'
13import { VideoBlacklistModel } from '../../video-blacklist'
14import { VideoChannelModel } from '../../video-channel'
15import { VideoFileModel } from '../../video-file'
16import { VideoLiveModel } from '../../video-live'
17import { VideoStreamingPlaylistModel } from '../../video-streaming-playlist'
18import { VideoTables } from './video-tables'
19
20type SQLRow = { [id: string]: string | number }
21
22/**
23 *
24 * Build video models from SQL rows
25 *
26 */
27
28export class VideoModelBuilder {
29 private videosMemo: { [ id: number ]: VideoModel }
30 private videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel }
31 private videoFileMemo: { [ id: number ]: VideoFileModel }
32
33 private thumbnailsDone: Set<any>
34 private historyDone: Set<any>
35 private blacklistDone: Set<any>
36 private liveDone: Set<any>
37 private redundancyDone: Set<any>
38 private scheduleVideoUpdateDone: Set<any>
39
40 private trackersDone: Set<string>
41 private tagsDone: Set<string>
42
43 private videos: VideoModel[]
44
45 private readonly buildOpts = { raw: true, isNewRecord: false }
46
47 constructor (
48 readonly mode: 'get' | 'list',
49 readonly tables: VideoTables
50 ) {
51
52 }
53
54 buildVideosFromRows (rows: SQLRow[], rowsWebTorrentFiles?: SQLRow[], rowsStreamingPlaylist?: SQLRow[]) {
55 this.reinit()
56
57 for (const row of rows) {
58 this.buildVideoAndAccount(row)
59
60 const videoModel = this.videosMemo[row.id]
61
62 this.setUserHistory(row, videoModel)
63 this.addThumbnail(row, videoModel)
64
65 if (!rowsWebTorrentFiles) {
66 this.addWebTorrentFile(row, videoModel)
67 }
68
69 if (!rowsStreamingPlaylist) {
70 this.addStreamingPlaylist(row, videoModel)
71 this.addStreamingPlaylistFile(row)
72 }
73
74 if (this.mode === 'get') {
75 this.addTag(row, videoModel)
76 this.addTracker(row, videoModel)
77 this.setBlacklisted(row, videoModel)
78 this.setScheduleVideoUpdate(row, videoModel)
79 this.setLive(row, videoModel)
80 }
81 }
82
83 this.grabSeparateWebTorrentFiles(rowsWebTorrentFiles)
84 this.grabSeparateStreamingPlaylistFiles(rowsStreamingPlaylist)
85
86 return this.videos
87 }
88
89 private reinit () {
90 this.videosMemo = {}
91 this.videoStreamingPlaylistMemo = {}
92 this.videoFileMemo = {}
93
94 this.thumbnailsDone = new Set<number>()
95 this.historyDone = new Set<number>()
96 this.blacklistDone = new Set<number>()
97 this.liveDone = new Set<number>()
98 this.redundancyDone = new Set<number>()
99 this.scheduleVideoUpdateDone = new Set<number>()
100
101 this.trackersDone = new Set<string>()
102 this.tagsDone = new Set<string>()
103
104 this.videos = []
105 }
106
107 private grabSeparateWebTorrentFiles (rowsWebTorrentFiles?: SQLRow[]) {
108 if (!rowsWebTorrentFiles) return
109
110 for (const row of rowsWebTorrentFiles) {
111 const id = row['VideoFiles.id']
112 if (!id) continue
113
114 const videoModel = this.videosMemo[row.id]
115 this.addWebTorrentFile(row, videoModel)
116 this.addRedundancy(row, 'VideoFiles', this.videoFileMemo[id])
117 }
118 }
119
120 private grabSeparateStreamingPlaylistFiles (rowsStreamingPlaylist?: SQLRow[]) {
121 if (!rowsStreamingPlaylist) return
122
123 for (const row of rowsStreamingPlaylist || []) {
124 const id = row['VideoStreamingPlaylists.id']
125 if (!id) continue
126
127 const videoModel = this.videosMemo[row.id]
128
129 this.addStreamingPlaylist(row, videoModel)
130 this.addStreamingPlaylistFile(row)
131 this.addRedundancy(row, 'VideoStreamingPlaylists', this.videoStreamingPlaylistMemo[id])
132 }
133 }
134
135 private buildVideoAndAccount (row: SQLRow) {
136 if (this.videosMemo[row.id]) return
137
138 const videoModel = new VideoModel(this.grab(row, this.tables.getVideoAttributes(), ''), this.buildOpts)
139
140 videoModel.UserVideoHistories = []
141 videoModel.Thumbnails = []
142 videoModel.VideoFiles = []
143 videoModel.VideoStreamingPlaylists = []
144 videoModel.Tags = []
145 videoModel.Trackers = []
146
147 this.buildAccount(row, videoModel)
148
149 this.videosMemo[row.id] = videoModel
150
151 // Keep rows order
152 this.videos.push(videoModel)
153 }
154
155 private buildAccount (row: SQLRow, videoModel: VideoModel) {
156 const id = row['VideoChannel.Account.id']
157 if (!id) return
158
159 const channelModel = new VideoChannelModel(this.grab(row, this.tables.getChannelAttributes(), 'VideoChannel'), this.buildOpts)
160 channelModel.Actor = this.buildActor(row, 'VideoChannel')
161
162 const accountModel = new AccountModel(this.grab(row, this.tables.getAccountAttributes(), 'VideoChannel.Account'), this.buildOpts)
163 accountModel.Actor = this.buildActor(row, 'VideoChannel.Account')
164
165 channelModel.Account = accountModel
166
167 videoModel.VideoChannel = channelModel
168 }
169
170 private buildActor (row: SQLRow, prefix: string) {
171 const actorPrefix = `${prefix}.Actor`
172 const avatarPrefix = `${actorPrefix}.Avatar`
173 const serverPrefix = `${actorPrefix}.Server`
174
175 const avatarModel = row[`${avatarPrefix}.id`] !== null
176 ? new ActorImageModel(this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix), this.buildOpts)
177 : null
178
179 const serverModel = row[`${serverPrefix}.id`] !== null
180 ? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts)
181 : null
182
183 const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts)
184 actorModel.Avatar = avatarModel
185 actorModel.Server = serverModel
186
187 return actorModel
188 }
189
190 private setUserHistory (row: SQLRow, videoModel: VideoModel) {
191 const id = row['userVideoHistory.id']
192 if (!id || this.historyDone.has(id)) return
193
194 const attributes = this.grab(row, this.tables.getUserHistoryAttributes(), 'userVideoHistory')
195 const historyModel = new UserVideoHistoryModel(attributes, this.buildOpts)
196 videoModel.UserVideoHistories.push(historyModel)
197
198 this.historyDone.add(id)
199 }
200
201 private addThumbnail (row: SQLRow, videoModel: VideoModel) {
202 const id = row['Thumbnails.id']
203 if (!id || this.thumbnailsDone.has(id)) return
204
205 const attributes = this.grab(row, this.tables.getThumbnailAttributes(), 'Thumbnails')
206 const thumbnailModel = new ThumbnailModel(attributes, this.buildOpts)
207 videoModel.Thumbnails.push(thumbnailModel)
208
209 this.thumbnailsDone.add(id)
210 }
211
212 private addWebTorrentFile (row: SQLRow, videoModel: VideoModel) {
213 const id = row['VideoFiles.id']
214 if (!id || this.videoFileMemo[id]) return
215
216 const attributes = this.grab(row, this.tables.getFileAttributes(), 'VideoFiles')
217 const videoFileModel = new VideoFileModel(attributes, this.buildOpts)
218 videoModel.VideoFiles.push(videoFileModel)
219
220 this.videoFileMemo[id] = videoFileModel
221 }
222
223 private addStreamingPlaylist (row: SQLRow, videoModel: VideoModel) {
224 const id = row['VideoStreamingPlaylists.id']
225 if (!id || this.videoStreamingPlaylistMemo[id]) return
226
227 const attributes = this.grab(row, this.tables.getStreamingPlaylistAttributes(), 'VideoStreamingPlaylists')
228 const streamingPlaylist = new VideoStreamingPlaylistModel(attributes, this.buildOpts)
229 streamingPlaylist.VideoFiles = []
230
231 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
232
233 this.videoStreamingPlaylistMemo[id] = streamingPlaylist
234 }
235
236 private addStreamingPlaylistFile (row: SQLRow) {
237 const id = row['VideoStreamingPlaylists.VideoFiles.id']
238 if (!id || this.videoFileMemo[id]) return
239
240 const streamingPlaylist = this.videoStreamingPlaylistMemo[row['VideoStreamingPlaylists.id']]
241
242 const attributes = this.grab(row, this.tables.getFileAttributes(), 'VideoStreamingPlaylists.VideoFiles')
243 const videoFileModel = new VideoFileModel(attributes, this.buildOpts)
244 streamingPlaylist.VideoFiles.push(videoFileModel)
245
246 this.videoFileMemo[id] = videoFileModel
247 }
248
249 private addRedundancy (row: SQLRow, prefix: string, to: VideoFileModel | VideoStreamingPlaylistModel) {
250 if (!to.RedundancyVideos) to.RedundancyVideos = []
251
252 const redundancyPrefix = `${prefix}.RedundancyVideos`
253 const id = row[`${redundancyPrefix}.id`]
254
255 if (!id || this.redundancyDone.has(id)) return
256
257 const attributes = this.grab(row, this.tables.getRedundancyAttributes(), redundancyPrefix)
258 const redundancyModel = new VideoRedundancyModel(attributes, this.buildOpts)
259 to.RedundancyVideos.push(redundancyModel)
260
261 this.redundancyDone.add(id)
262 }
263
264 private addTag (row: SQLRow, videoModel: VideoModel) {
265 if (!row['Tags.name']) return
266
267 const key = `${row['Tags.VideoTagModel.videoId']}-${row['Tags.VideoTagModel.tagId']}`
268 if (this.tagsDone.has(key)) return
269
270 const attributes = this.grab(row, this.tables.getTagAttributes(), 'Tags')
271 const tagModel = new TagModel(attributes, this.buildOpts)
272 videoModel.Tags.push(tagModel)
273
274 this.tagsDone.add(key)
275 }
276
277 private addTracker (row: SQLRow, videoModel: VideoModel) {
278 if (!row['Trackers.id']) return
279
280 const key = `${row['Trackers.VideoTrackerModel.videoId']}-${row['Trackers.VideoTrackerModel.trackerId']}`
281 if (this.trackersDone.has(key)) return
282
283 const attributes = this.grab(row, this.tables.getTrackerAttributes(), 'Trackers')
284 const trackerModel = new TrackerModel(attributes, this.buildOpts)
285 videoModel.Trackers.push(trackerModel)
286
287 this.trackersDone.add(key)
288 }
289
290 private setBlacklisted (row: SQLRow, videoModel: VideoModel) {
291 const id = row['VideoBlacklist.id']
292 if (!id || this.blacklistDone.has(id)) return
293
294 const attributes = this.grab(row, this.tables.getBlacklistedAttributes(), 'VideoBlacklist')
295 videoModel.VideoBlacklist = new VideoBlacklistModel(attributes, this.buildOpts)
296
297 this.blacklistDone.add(id)
298 }
299
300 private setScheduleVideoUpdate (row: SQLRow, videoModel: VideoModel) {
301 const id = row['ScheduleVideoUpdate.id']
302 if (!id || this.scheduleVideoUpdateDone.has(id)) return
303
304 const attributes = this.grab(row, this.tables.getScheduleUpdateAttributes(), 'ScheduleVideoUpdate')
305 videoModel.ScheduleVideoUpdate = new ScheduleVideoUpdateModel(attributes, this.buildOpts)
306
307 this.scheduleVideoUpdateDone.add(id)
308 }
309
310 private setLive (row: SQLRow, videoModel: VideoModel) {
311 const id = row['VideoLive.id']
312 if (!id || this.liveDone.has(id)) return
313
314 const attributes = this.grab(row, this.tables.getLiveAttributes(), 'VideoLive')
315 videoModel.VideoLive = new VideoLiveModel(attributes, this.buildOpts)
316
317 this.liveDone.add(id)
318 }
319
320 private grab (row: SQLRow, attributes: string[], prefix: string) {
321 const result: { [ id: string ]: string | number } = {}
322
323 for (const a of attributes) {
324 const key = prefix
325 ? prefix + '.' + a
326 : a
327
328 result[a] = row[key]
329 }
330
331 return result
332 }
333}
diff --git a/server/models/video/sql/shared/video-tables.ts b/server/models/video/sql/shared/video-tables.ts
new file mode 100644
index 000000000..abdd22188
--- /dev/null
+++ b/server/models/video/sql/shared/video-tables.ts
@@ -0,0 +1,263 @@
1
2/**
3 *
4 * Class to build video attributes/join names we want to fetch from the database
5 *
6 */
7export class VideoTables {
8
9 constructor (readonly mode: 'get' | 'list') {
10
11 }
12
13 getChannelAttributesForUser () {
14 return [ 'id', 'accountId' ]
15 }
16
17 getChannelAttributes () {
18 let attributeKeys = [
19 'id',
20 'name',
21 'description',
22 'actorId'
23 ]
24
25 if (this.mode === 'get') {
26 attributeKeys = attributeKeys.concat([
27 'support',
28 'createdAt',
29 'updatedAt'
30 ])
31 }
32
33 return attributeKeys
34 }
35
36 getUserAccountAttributes () {
37 return [ 'id', 'userId' ]
38 }
39
40 getAccountAttributes () {
41 let attributeKeys = [ 'id', 'name', 'actorId' ]
42
43 if (this.mode === 'get') {
44 attributeKeys = attributeKeys.concat([
45 'description',
46 'userId',
47 'createdAt',
48 'updatedAt'
49 ])
50 }
51
52 return attributeKeys
53 }
54
55 getThumbnailAttributes () {
56 let attributeKeys = [ 'id', 'type', 'filename' ]
57
58 if (this.mode === 'get') {
59 attributeKeys = attributeKeys.concat([
60 'height',
61 'width',
62 'fileUrl',
63 'automaticallyGenerated',
64 'videoId',
65 'videoPlaylistId',
66 'createdAt',
67 'updatedAt'
68 ])
69 }
70
71 return attributeKeys
72 }
73
74 getFileAttributes () {
75 return [
76 'id',
77 'createdAt',
78 'updatedAt',
79 'resolution',
80 'size',
81 'extname',
82 'filename',
83 'fileUrl',
84 'torrentFilename',
85 'torrentUrl',
86 'infoHash',
87 'fps',
88 'metadataUrl',
89 'videoStreamingPlaylistId',
90 'videoId'
91 ]
92 }
93
94 getStreamingPlaylistAttributes () {
95 let playlistKeys = [ 'id', 'playlistUrl', 'type' ]
96
97 if (this.mode === 'get') {
98 playlistKeys = playlistKeys.concat([
99 'p2pMediaLoaderInfohashes',
100 'p2pMediaLoaderPeerVersion',
101 'segmentsSha256Url',
102 'videoId',
103 'createdAt',
104 'updatedAt'
105 ])
106 }
107
108 return playlistKeys
109 }
110
111 getUserHistoryAttributes () {
112 return [ 'id', 'currentTime' ]
113 }
114
115 getPlaylistAttributes () {
116 return [
117 'createdAt',
118 'updatedAt',
119 'url',
120 'position',
121 'startTimestamp',
122 'stopTimestamp',
123 'videoPlaylistId'
124 ]
125 }
126
127 getTagAttributes () {
128 return [ 'id', 'name' ]
129 }
130
131 getVideoTagAttributes () {
132 return [ 'videoId', 'tagId', 'createdAt', 'updatedAt' ]
133 }
134
135 getBlacklistedAttributes () {
136 return [ 'id', 'reason', 'unfederated' ]
137 }
138
139 getScheduleUpdateAttributes () {
140 return [
141 'id',
142 'updateAt',
143 'privacy',
144 'videoId',
145 'createdAt',
146 'updatedAt'
147 ]
148 }
149
150 getLiveAttributes () {
151 return [
152 'id',
153 'streamKey',
154 'saveReplay',
155 'permanentLive',
156 'videoId',
157 'createdAt',
158 'updatedAt'
159 ]
160 }
161
162 getTrackerAttributes () {
163 return [ 'id', 'url' ]
164 }
165
166 getVideoTrackerAttributes () {
167 return [
168 'videoId',
169 'trackerId',
170 'createdAt',
171 'updatedAt'
172 ]
173 }
174
175 getRedundancyAttributes () {
176 return [ 'id', 'fileUrl' ]
177 }
178
179 getActorAttributes () {
180 let attributeKeys = [
181 'id',
182 'preferredUsername',
183 'url',
184 'serverId',
185 'avatarId'
186 ]
187
188 if (this.mode === 'get') {
189 attributeKeys = attributeKeys.concat([
190 'type',
191 'followersCount',
192 'followingCount',
193 'inboxUrl',
194 'outboxUrl',
195 'sharedInboxUrl',
196 'followersUrl',
197 'followingUrl',
198 'remoteCreatedAt',
199 'createdAt',
200 'updatedAt'
201 ])
202 }
203
204 return attributeKeys
205 }
206
207 getAvatarAttributes () {
208 let attributeKeys = [
209 'id',
210 'filename',
211 'type',
212 'fileUrl',
213 'onDisk',
214 'createdAt',
215 'updatedAt'
216 ]
217
218 if (this.mode === 'get') {
219 attributeKeys = attributeKeys.concat([
220 'height',
221 'width',
222 'type'
223 ])
224 }
225
226 return attributeKeys
227 }
228
229 getServerAttributes () {
230 return [ 'id', 'host' ]
231 }
232
233 getVideoAttributes () {
234 return [
235 'id',
236 'uuid',
237 'name',
238 'category',
239 'licence',
240 'language',
241 'privacy',
242 'nsfw',
243 'description',
244 'support',
245 'duration',
246 'views',
247 'likes',
248 'dislikes',
249 'remote',
250 'isLive',
251 'url',
252 'commentsEnabled',
253 'downloadEnabled',
254 'waitTranscoding',
255 'state',
256 'publishedAt',
257 'originallyPublishedAt',
258 'channelId',
259 'createdAt',
260 'updatedAt'
261 ]
262 }
263}
diff --git a/server/models/video/sql/video-model-get-query-builder.ts b/server/models/video/sql/video-model-get-query-builder.ts
new file mode 100644
index 000000000..f234e8778
--- /dev/null
+++ b/server/models/video/sql/video-model-get-query-builder.ts
@@ -0,0 +1,173 @@
1import { Sequelize, Transaction } from 'sequelize'
2import { AbstractVideosModelQueryBuilder } from './shared/abstract-videos-model-query-builder'
3import { VideoFileQueryBuilder } from './shared/video-file-query-builder'
4import { VideoModelBuilder } from './shared/video-model-builder'
5import { VideoTables } from './shared/video-tables'
6
7/**
8 *
9 * Build a GET SQL query, fetch rows and create the video model
10 *
11 */
12
13export type GetType =
14 'api' |
15 'full-light' |
16 'account-blacklist-files' |
17 'all-files' |
18 'thumbnails' |
19 'thumbnails-blacklist' |
20 'id' |
21 'blacklist-rights'
22
23export type BuildVideoGetQueryOptions = {
24 id?: number | string
25 url?: string
26
27 type: GetType
28
29 userId?: number
30 transaction?: Transaction
31
32 logging?: boolean
33}
34
35export class VideosModelGetQueryBuilder {
36 videoQueryBuilder: VideosModelGetQuerySubBuilder
37 webtorrentFilesQueryBuilder: VideoFileQueryBuilder
38 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder
39
40 private readonly videoModelBuilder: VideoModelBuilder
41
42 private static readonly videoFilesInclude = new Set<GetType>([ 'api', 'full-light', 'account-blacklist-files', 'all-files' ])
43
44 constructor (protected readonly sequelize: Sequelize) {
45 this.videoQueryBuilder = new VideosModelGetQuerySubBuilder(sequelize)
46 this.webtorrentFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
47 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
48
49 this.videoModelBuilder = new VideoModelBuilder('get', new VideoTables('get'))
50 }
51
52 async queryVideo (options: BuildVideoGetQueryOptions) {
53 const [ videoRows, webtorrentFilesRows, streamingPlaylistFilesRows ] = await Promise.all([
54 this.videoQueryBuilder.queryVideos(options),
55
56 VideosModelGetQueryBuilder.videoFilesInclude.has(options.type)
57 ? this.webtorrentFilesQueryBuilder.queryWebTorrentVideos(options)
58 : Promise.resolve(undefined),
59
60 VideosModelGetQueryBuilder.videoFilesInclude.has(options.type)
61 ? this.streamingPlaylistFilesQueryBuilder.queryStreamingPlaylistVideos(options)
62 : Promise.resolve(undefined)
63 ])
64
65 const videos = this.videoModelBuilder.buildVideosFromRows(videoRows, webtorrentFilesRows, streamingPlaylistFilesRows)
66
67 if (videos.length > 1) {
68 throw new Error('Video results is more than ')
69 }
70
71 if (videos.length === 0) return null
72 return videos[0]
73 }
74}
75
76export class VideosModelGetQuerySubBuilder extends AbstractVideosModelQueryBuilder {
77 protected attributes: { [key: string]: string }
78
79 protected webtorrentFilesQuery: string
80 protected streamingPlaylistFilesQuery: string
81
82 private static readonly trackersInclude = new Set<GetType>([ 'api' ])
83 private static readonly liveInclude = new Set<GetType>([ 'api', 'full-light' ])
84 private static readonly scheduleUpdateInclude = new Set<GetType>([ 'api', 'full-light' ])
85 private static readonly tagsInclude = new Set<GetType>([ 'api', 'full-light' ])
86 private static readonly userHistoryInclude = new Set<GetType>([ 'api', 'full-light' ])
87 private static readonly accountInclude = new Set<GetType>([ 'api', 'full-light', 'account-blacklist-files' ])
88 private static readonly ownerUserInclude = new Set<GetType>([ 'blacklist-rights' ])
89
90 private static readonly blacklistedInclude = new Set<GetType>([
91 'api',
92 'full-light',
93 'account-blacklist-files',
94 'thumbnails-blacklist',
95 'blacklist-rights'
96 ])
97
98 private static readonly thumbnailsInclude = new Set<GetType>([
99 'api',
100 'full-light',
101 'account-blacklist-files',
102 'all-files',
103 'thumbnails',
104 'thumbnails-blacklist'
105 ])
106
107 constructor (protected readonly sequelize: Sequelize) {
108 super('get')
109 }
110
111 queryVideos (options: BuildVideoGetQueryOptions) {
112 this.buildMainGetQuery(options)
113
114 return this.runQuery(options)
115 }
116
117 private buildMainGetQuery (options: BuildVideoGetQueryOptions) {
118 this.attributes = {
119 '"video".*': ''
120 }
121
122 if (VideosModelGetQuerySubBuilder.thumbnailsInclude.has(options.type)) {
123 this.includeThumbnails()
124 }
125
126 if (VideosModelGetQuerySubBuilder.blacklistedInclude.has(options.type)) {
127 this.includeBlacklisted()
128 }
129
130 if (VideosModelGetQuerySubBuilder.accountInclude.has(options.type)) {
131 this.includeChannels()
132 this.includeAccounts()
133 }
134
135 if (VideosModelGetQuerySubBuilder.tagsInclude.has(options.type)) {
136 this.includeTags()
137 }
138
139 if (VideosModelGetQuerySubBuilder.scheduleUpdateInclude.has(options.type)) {
140 this.includeScheduleUpdate()
141 }
142
143 if (VideosModelGetQuerySubBuilder.liveInclude.has(options.type)) {
144 this.includeLive()
145 }
146
147 if (options.userId && VideosModelGetQuerySubBuilder.userHistoryInclude.has(options.type)) {
148 this.includeUserHistory(options.userId)
149 }
150
151 if (VideosModelGetQuerySubBuilder.ownerUserInclude.has(options.type)) {
152 this.includeOwnerUser()
153 }
154
155 if (VideosModelGetQuerySubBuilder.trackersInclude.has(options.type)) {
156 this.includeTrackers()
157 }
158
159 this.whereId(options)
160
161 this.query = this.buildQuery(options)
162 }
163
164 private buildQuery (options: BuildVideoGetQueryOptions) {
165 const order = VideosModelGetQuerySubBuilder.tagsInclude.has(options.type)
166 ? 'ORDER BY "Tags"."name" ASC'
167 : ''
168
169 const from = `SELECT * FROM "video" ${this.where} LIMIT 1`
170
171 return `${this.buildSelect()} FROM (${from}) AS "video" ${this.joins} ${order}`
172 }
173}
diff --git a/server/models/video/sql/videos-id-list-query-builder.ts b/server/models/video/sql/videos-id-list-query-builder.ts
new file mode 100644
index 000000000..30b251f0f
--- /dev/null
+++ b/server/models/video/sql/videos-id-list-query-builder.ts
@@ -0,0 +1,616 @@
1import { Sequelize } from 'sequelize'
2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc'
4import { buildDirectionAndField, createSafeIn } from '@server/models/utils'
5import { MUserAccountId, MUserId } from '@server/types/models'
6import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models'
7import { AbstractVideosQueryBuilder } from './shared/abstract-videos-query-builder'
8
9/**
10 *
11 * Build videos list SQL query to fetch rows
12 *
13 */
14
15export type BuildVideosListQueryOptions = {
16 attributes?: string[]
17
18 serverAccountId: number
19 followerActorId: number
20 includeLocalVideos: boolean
21
22 count: number
23 start: number
24 sort: string
25
26 nsfw?: boolean
27 filter?: VideoFilter
28 isLive?: boolean
29
30 categoryOneOf?: number[]
31 licenceOneOf?: number[]
32 languageOneOf?: string[]
33 tagsOneOf?: string[]
34 tagsAllOf?: string[]
35
36 withFiles?: boolean
37
38 accountId?: number
39 videoChannelId?: number
40
41 videoPlaylistId?: number
42
43 trendingAlgorithm?: string // best, hot, or any other algorithm implemented
44 trendingDays?: number
45
46 user?: MUserAccountId
47 historyOfUser?: MUserId
48
49 startDate?: string // ISO 8601
50 endDate?: string // ISO 8601
51 originallyPublishedStartDate?: string
52 originallyPublishedEndDate?: string
53
54 durationMin?: number // seconds
55 durationMax?: number // seconds
56
57 search?: string
58
59 isCount?: boolean
60
61 group?: string
62 having?: string
63}
64
65export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
66 protected replacements: any = {}
67
68 private attributes: string[]
69 private joins: string[] = []
70
71 private readonly and: string[] = []
72
73 private readonly cte: string[] = []
74
75 private group = ''
76 private having = ''
77
78 private sort = ''
79 private limit = ''
80 private offset = ''
81
82 constructor (protected readonly sequelize: Sequelize) {
83 super()
84 }
85
86 queryVideoIds (options: BuildVideosListQueryOptions) {
87 this.buildIdsListQuery(options)
88
89 return this.runQuery()
90 }
91
92 countVideoIds (countOptions: BuildVideosListQueryOptions): Promise<number> {
93 this.buildIdsListQuery(countOptions)
94
95 return this.runQuery().then(rows => rows.length !== 0 ? rows[0].total : 0)
96 }
97
98 getIdsListQueryAndSort (options: BuildVideosListQueryOptions) {
99 this.buildIdsListQuery(options)
100 return { query: this.query, sort: this.sort, replacements: this.replacements }
101 }
102
103 private buildIdsListQuery (options: BuildVideosListQueryOptions) {
104 this.attributes = options.attributes || [ '"video"."id"' ]
105
106 if (options.group) this.group = options.group
107 if (options.having) this.having = options.having
108
109 this.joins = this.joins.concat([
110 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"',
111 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"',
112 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"'
113 ])
114
115 this.whereNotBlacklisted()
116
117 if (options.serverAccountId) {
118 this.whereNotBlocked(options.serverAccountId, options.user)
119 }
120
121 // Only list public/published videos
122 if (!options.filter || (options.filter !== 'all-local' && options.filter !== 'all')) {
123 this.whereStateAndPrivacyAvailable(options.user)
124 }
125
126 if (options.videoPlaylistId) {
127 this.joinPlaylist(options.videoPlaylistId)
128 }
129
130 if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) {
131 this.whereOnlyLocal()
132 }
133
134 if (options.accountId) {
135 this.whereAccountId(options.accountId)
136 }
137
138 if (options.videoChannelId) {
139 this.whereChannelId(options.videoChannelId)
140 }
141
142 if (options.followerActorId) {
143 this.whereFollowerActorId(options.followerActorId, options.includeLocalVideos)
144 }
145
146 if (options.withFiles === true) {
147 this.whereFileExists()
148 }
149
150 if (options.tagsOneOf) {
151 this.whereTagsOneOf(options.tagsOneOf)
152 }
153
154 if (options.tagsAllOf) {
155 this.whereTagsAllOf(options.tagsAllOf)
156 }
157
158 if (options.nsfw === true) {
159 this.whereNSFW()
160 } else if (options.nsfw === false) {
161 this.whereSFW()
162 }
163
164 if (options.isLive === true) {
165 this.whereLive()
166 } else if (options.isLive === false) {
167 this.whereVOD()
168 }
169
170 if (options.categoryOneOf) {
171 this.whereCategoryOneOf(options.categoryOneOf)
172 }
173
174 if (options.licenceOneOf) {
175 this.whereLicenceOneOf(options.licenceOneOf)
176 }
177
178 if (options.languageOneOf) {
179 this.whereLanguageOneOf(options.languageOneOf)
180 }
181
182 // We don't exclude results in this so if we do a count we don't need to add this complex clause
183 if (options.isCount !== true) {
184 if (options.trendingDays) {
185 this.groupForTrending(options.trendingDays)
186 } else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) {
187 this.groupForHotOrBest(options.trendingAlgorithm, options.user)
188 }
189 }
190
191 if (options.historyOfUser) {
192 this.joinHistory(options.historyOfUser.id)
193 }
194
195 if (options.startDate) {
196 this.whereStartDate(options.startDate)
197 }
198
199 if (options.endDate) {
200 this.whereEndDate(options.endDate)
201 }
202
203 if (options.originallyPublishedStartDate) {
204 this.whereOriginallyPublishedStartDate(options.originallyPublishedStartDate)
205 }
206
207 if (options.originallyPublishedEndDate) {
208 this.whereOriginallyPublishedEndDate(options.originallyPublishedEndDate)
209 }
210
211 if (options.durationMin) {
212 this.whereDurationMin(options.durationMin)
213 }
214
215 if (options.durationMax) {
216 this.whereDurationMax(options.durationMax)
217 }
218
219 this.whereSearch(options.search)
220
221 if (options.isCount === true) {
222 this.setCountAttribute()
223 } else {
224 if (exists(options.sort)) {
225 this.setSort(options.sort)
226 }
227
228 if (exists(options.count)) {
229 this.setLimit(options.count)
230 }
231
232 if (exists(options.start)) {
233 this.setOffset(options.start)
234 }
235 }
236
237 const cteString = this.cte.length !== 0
238 ? `WITH ${this.cte.join(', ')} `
239 : ''
240
241 this.query = cteString +
242 'SELECT ' + this.attributes.join(', ') + ' ' +
243 'FROM "video" ' + this.joins.join(' ') + ' ' +
244 'WHERE ' + this.and.join(' AND ') + ' ' +
245 this.group + ' ' +
246 this.having + ' ' +
247 this.sort + ' ' +
248 this.limit + ' ' +
249 this.offset
250 }
251
252 private setCountAttribute () {
253 this.attributes = [ 'COUNT(*) as "total"' ]
254 }
255
256 private joinHistory (userId: number) {
257 this.joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"')
258
259 this.and.push('"userVideoHistory"."userId" = :historyOfUser')
260
261 this.replacements.historyOfUser = userId
262 }
263
264 private joinPlaylist (playlistId: number) {
265 this.joins.push(
266 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
267 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
268 )
269
270 this.replacements.videoPlaylistId = playlistId
271 }
272
273 private whereStateAndPrivacyAvailable (user?: MUserAccountId) {
274 this.and.push(
275 `("video"."state" = ${VideoState.PUBLISHED} OR ` +
276 `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
277 )
278
279 if (user) {
280 this.and.push(
281 `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
282 )
283 } else { // Or only public videos
284 this.and.push(
285 `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
286 )
287 }
288 }
289
290 private whereOnlyLocal () {
291 this.and.push('"video"."remote" IS FALSE')
292 }
293
294 private whereAccountId (accountId: number) {
295 this.and.push('"account"."id" = :accountId')
296 this.replacements.accountId = accountId
297 }
298
299 private whereChannelId (channelId: number) {
300 this.and.push('"videoChannel"."id" = :videoChannelId')
301 this.replacements.videoChannelId = channelId
302 }
303
304 private whereFollowerActorId (followerActorId: number, includeLocalVideos: boolean) {
305 let query =
306 '(' +
307 ' EXISTS (' +
308 ' SELECT 1 FROM "videoShare" ' +
309 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
310 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
311 ' WHERE "videoShare"."videoId" = "video"."id"' +
312 ' )' +
313 ' OR' +
314 ' EXISTS (' +
315 ' SELECT 1 from "actorFollow" ' +
316 ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
317 ' AND "actorFollow"."state" = \'accepted\'' +
318 ' )'
319
320 if (includeLocalVideos) {
321 query += ' OR "video"."remote" IS FALSE'
322 }
323
324 query += ')'
325
326 this.and.push(query)
327 this.replacements.followerActorId = followerActorId
328 }
329
330 private whereFileExists () {
331 this.and.push(
332 '(' +
333 ' EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id") ' +
334 ' OR EXISTS (' +
335 ' SELECT 1 FROM "videoStreamingPlaylist" ' +
336 ' INNER JOIN "videoFile" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ' +
337 ' WHERE "videoStreamingPlaylist"."videoId" = "video"."id"' +
338 ' )' +
339 ')'
340 )
341 }
342
343 private whereTagsOneOf (tagsOneOf: string[]) {
344 const tagsOneOfLower = tagsOneOf.map(t => t.toLowerCase())
345
346 this.and.push(
347 'EXISTS (' +
348 ' SELECT 1 FROM "videoTag" ' +
349 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
350 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsOneOfLower) + ') ' +
351 ' AND "video"."id" = "videoTag"."videoId"' +
352 ')'
353 )
354 }
355
356 private whereTagsAllOf (tagsAllOf: string[]) {
357 const tagsAllOfLower = tagsAllOf.map(t => t.toLowerCase())
358
359 this.and.push(
360 'EXISTS (' +
361 ' SELECT 1 FROM "videoTag" ' +
362 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
363 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsAllOfLower) + ') ' +
364 ' AND "video"."id" = "videoTag"."videoId" ' +
365 ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
366 ')'
367 )
368 }
369
370 private whereCategoryOneOf (categoryOneOf: number[]) {
371 this.and.push('"video"."category" IN (:categoryOneOf)')
372 this.replacements.categoryOneOf = categoryOneOf
373 }
374
375 private whereLicenceOneOf (licenceOneOf: number[]) {
376 this.and.push('"video"."licence" IN (:licenceOneOf)')
377 this.replacements.licenceOneOf = licenceOneOf
378 }
379
380 private whereLanguageOneOf (languageOneOf: string[]) {
381 const languages = languageOneOf.filter(l => l && l !== '_unknown')
382 const languagesQueryParts: string[] = []
383
384 if (languages.length !== 0) {
385 languagesQueryParts.push('"video"."language" IN (:languageOneOf)')
386 this.replacements.languageOneOf = languages
387
388 languagesQueryParts.push(
389 'EXISTS (' +
390 ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
391 ' IN (' + createSafeIn(this.sequelize, languages) + ') AND ' +
392 ' "videoCaption"."videoId" = "video"."id"' +
393 ')'
394 )
395 }
396
397 if (languageOneOf.includes('_unknown')) {
398 languagesQueryParts.push('"video"."language" IS NULL')
399 }
400
401 if (languagesQueryParts.length !== 0) {
402 this.and.push('(' + languagesQueryParts.join(' OR ') + ')')
403 }
404 }
405
406 private whereNSFW () {
407 this.and.push('"video"."nsfw" IS TRUE')
408 }
409
410 private whereSFW () {
411 this.and.push('"video"."nsfw" IS FALSE')
412 }
413
414 private whereLive () {
415 this.and.push('"video"."isLive" IS TRUE')
416 }
417
418 private whereVOD () {
419 this.and.push('"video"."isLive" IS FALSE')
420 }
421
422 private whereNotBlocked (serverAccountId: number, user?: MUserAccountId) {
423 const blockerIds = [ serverAccountId ]
424 if (user) blockerIds.push(user.Account.id)
425
426 const inClause = createSafeIn(this.sequelize, blockerIds)
427
428 this.and.push(
429 'NOT EXISTS (' +
430 ' SELECT 1 FROM "accountBlocklist" ' +
431 ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' +
432 ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' +
433 ')' +
434 'AND NOT EXISTS (' +
435 ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' +
436 ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' +
437 ')'
438 )
439 }
440
441 private whereSearch (search?: string) {
442 if (!search) {
443 this.attributes.push('0 as similarity')
444 return
445 }
446
447 const escapedSearch = this.sequelize.escape(search)
448 const escapedLikeSearch = this.sequelize.escape('%' + search + '%')
449
450 this.cte.push(
451 '"trigramSearch" AS (' +
452 ' SELECT "video"."id", ' +
453 ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` +
454 ' FROM "video" ' +
455 ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
456 ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
457 ')'
458 )
459
460 this.joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
461
462 let base = '(' +
463 ' "trigramSearch"."id" IS NOT NULL OR ' +
464 ' EXISTS (' +
465 ' SELECT 1 FROM "videoTag" ' +
466 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
467 ` WHERE lower("tag"."name") = ${escapedSearch} ` +
468 ' AND "video"."id" = "videoTag"."videoId"' +
469 ' )'
470
471 if (validator.isUUID(search)) {
472 base += ` OR "video"."uuid" = ${escapedSearch}`
473 }
474
475 base += ')'
476
477 this.and.push(base)
478 this.attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`)
479 }
480
481 private whereNotBlacklisted () {
482 this.and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
483 }
484
485 private whereStartDate (startDate: string) {
486 this.and.push('"video"."publishedAt" >= :startDate')
487 this.replacements.startDate = startDate
488 }
489
490 private whereEndDate (endDate: string) {
491 this.and.push('"video"."publishedAt" <= :endDate')
492 this.replacements.endDate = endDate
493 }
494
495 private whereOriginallyPublishedStartDate (startDate: string) {
496 this.and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
497 this.replacements.originallyPublishedStartDate = startDate
498 }
499
500 private whereOriginallyPublishedEndDate (endDate: string) {
501 this.and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
502 this.replacements.originallyPublishedEndDate = endDate
503 }
504
505 private whereDurationMin (durationMin: number) {
506 this.and.push('"video"."duration" >= :durationMin')
507 this.replacements.durationMin = durationMin
508 }
509
510 private whereDurationMax (durationMax: number) {
511 this.and.push('"video"."duration" <= :durationMax')
512 this.replacements.durationMax = durationMax
513 }
514
515 private groupForTrending (trendingDays: number) {
516 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
517
518 this.joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
519 this.replacements.viewsGteDate = viewsGteDate
520
521 this.attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
522
523 this.group = 'GROUP BY "video"."id"'
524 }
525
526 private groupForHotOrBest (trendingAlgorithm: string, user?: MUserAccountId) {
527 /**
528 * "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
529 * with fixed weights only applied to their log values.
530 *
531 * This algorithm gives little chance for an old video to have a good score,
532 * for which recent spikes in interactions could be a sign of "hotness" and
533 * justify a better score. However there are multiple ways to achieve that
534 * goal, which is left for later. Yes, this is a TODO :)
535 *
536 * notes:
537 * - weights and base score are in number of half-days.
538 * - all comments are counted, regardless of being written by the video author or not
539 * see https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L47-L58
540 * - we have less interactions than on reddit, so multiply weights by an arbitrary factor
541 */
542 const weights = {
543 like: 3 * 50,
544 dislike: -3 * 50,
545 view: Math.floor((1 / 3) * 50),
546 comment: 2 * 50, // a comment takes more time than a like to do, but can be done multiple times
547 history: -2 * 50
548 }
549
550 this.joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
551
552 let attribute =
553 `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
554 `+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
555 `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
556 `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
557 '+ (SELECT (EXTRACT(epoch FROM "video"."publishedAt") - 1446156582) / 47000) ' // base score (in number of half-days)
558
559 if (trendingAlgorithm === 'best' && user) {
560 this.joins.push(
561 'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser'
562 )
563 this.replacements.bestUser = user.id
564
565 attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} `
566 }
567
568 attribute += 'AS "score"'
569 this.attributes.push(attribute)
570
571 this.group = 'GROUP BY "video"."id"'
572 }
573
574 private setSort (sort: string) {
575 if (sort === '-originallyPublishedAt' || sort === 'originallyPublishedAt') {
576 this.attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
577 }
578
579 this.sort = this.buildOrder(sort)
580 }
581
582 private buildOrder (value: string) {
583 const { direction, field } = buildDirectionAndField(value)
584 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
585
586 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
587
588 if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
589 return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
590 }
591
592 let firstSort: string
593
594 if (field.toLowerCase() === 'match') { // Search
595 firstSort = '"similarity"'
596 } else if (field === 'originallyPublishedAt') {
597 firstSort = '"publishedAtForOrder"'
598 } else if (field.includes('.')) {
599 firstSort = field
600 } else {
601 firstSort = `"video"."${field}"`
602 }
603
604 return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
605 }
606
607 private setLimit (countArg: number) {
608 const count = parseInt(countArg + '', 10)
609 this.limit = `LIMIT ${count}`
610 }
611
612 private setOffset (startArg: number) {
613 const start = parseInt(startArg + '', 10)
614 this.offset = `OFFSET ${start}`
615 }
616}
diff --git a/server/models/video/sql/videos-model-list-query-builder.ts b/server/models/video/sql/videos-model-list-query-builder.ts
new file mode 100644
index 000000000..e61c51de8
--- /dev/null
+++ b/server/models/video/sql/videos-model-list-query-builder.ts
@@ -0,0 +1,71 @@
1import { Sequelize } from 'sequelize'
2import { AbstractVideosModelQueryBuilder } from './shared/abstract-videos-model-query-builder'
3import { VideoModelBuilder } from './shared/video-model-builder'
4import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './videos-id-list-query-builder'
5
6/**
7 *
8 * Build videos list SQL query and create video models
9 *
10 */
11
12export class VideosModelListQueryBuilder extends AbstractVideosModelQueryBuilder {
13 protected attributes: { [key: string]: string }
14
15 private innerQuery: string
16 private innerSort: string
17
18 private readonly videoModelBuilder: VideoModelBuilder
19
20 constructor (protected readonly sequelize: Sequelize) {
21 super('list')
22
23 this.videoModelBuilder = new VideoModelBuilder(this.mode, this.tables)
24 }
25
26 queryVideos (options: BuildVideosListQueryOptions) {
27 this.buildInnerQuery(options)
28 this.buildListQueryFromIdsQuery(options)
29
30 return this.runQuery()
31 .then(rows => this.videoModelBuilder.buildVideosFromRows(rows))
32 }
33
34 private buildInnerQuery (options: BuildVideosListQueryOptions) {
35 const idsQueryBuilder = new VideosIdListQueryBuilder(this.sequelize)
36 const { query, sort, replacements } = idsQueryBuilder.getIdsListQueryAndSort(options)
37
38 this.replacements = replacements
39 this.innerQuery = query
40 this.innerSort = sort
41 }
42
43 private buildListQueryFromIdsQuery (options: BuildVideosListQueryOptions) {
44 this.attributes = {
45 '"video".*': ''
46 }
47
48 this.addJoin('INNER JOIN "video" ON "tmp"."id" = "video"."id"')
49
50 this.includeChannels()
51 this.includeAccounts()
52 this.includeThumbnails()
53
54 if (options.withFiles) {
55 this.includeWebtorrentFiles()
56 this.includeStreamingPlaylistFiles()
57 }
58
59 if (options.user) {
60 this.includeUserHistory(options.user.id)
61 }
62
63 if (options.videoPlaylistId) {
64 this.includePlaylist(options.videoPlaylistId)
65 }
66
67 const select = this.buildSelect()
68
69 this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}`
70 }
71}
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index d04205703..c1eebe27f 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -1,6 +1,7 @@
1import { col, fn, QueryTypes, Transaction } from 'sequelize' 1import { col, fn, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MTag } from '@server/types/models' 3import { MTag } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { VideoPrivacy, VideoState } from '../../../shared/models/videos' 5import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
5import { isVideoTagValid } from '../../helpers/custom-validators/videos' 6import { isVideoTagValid } from '../../helpers/custom-validators/videos'
6import { throwIfNotValid } from '../utils' 7import { throwIfNotValid } from '../utils'
@@ -21,7 +22,7 @@ import { VideoTagModel } from './video-tag'
21 } 22 }
22 ] 23 ]
23}) 24})
24export class TagModel extends Model { 25export class TagModel extends Model<Partial<AttributesOnly<TagModel>>> {
25 26
26 @AllowNull(false) 27 @AllowNull(false)
27 @Is('VideoTag', value => throwIfNotValid(value, isVideoTagValid, 'tag')) 28 @Is('VideoTag', value => throwIfNotValid(value, isVideoTagValid, 'tag'))
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
index f1187c8d6..3388478d9 100644
--- a/server/models/video/thumbnail.ts
+++ b/server/models/video/thumbnail.ts
@@ -17,6 +17,7 @@ import {
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { afterCommitIfTransaction } from '@server/helpers/database-utils' 18import { afterCommitIfTransaction } from '@server/helpers/database-utils'
19import { MThumbnail, MThumbnailVideo, MVideo } from '@server/types/models' 19import { MThumbnail, MThumbnailVideo, MVideo } from '@server/types/models'
20import { AttributesOnly } from '@shared/core-utils'
20import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' 21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
21import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
22import { CONFIG } from '../../initializers/config' 23import { CONFIG } from '../../initializers/config'
@@ -40,7 +41,7 @@ import { VideoPlaylistModel } from './video-playlist'
40 } 41 }
41 ] 42 ]
42}) 43})
43export class ThumbnailModel extends Model { 44export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>>> {
44 45
45 @AllowNull(false) 46 @AllowNull(false)
46 @Column 47 @Column
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index aa18896da..98f4ec9c5 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,6 +1,7 @@
1import { FindOptions } from 'sequelize' 1import { FindOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MVideoBlacklist, MVideoBlacklistFormattable } from '@server/types/models' 3import { MVideoBlacklist, MVideoBlacklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' 5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
5import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' 6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
6import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
@@ -18,7 +19,7 @@ import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel
18 } 19 }
19 ] 20 ]
20}) 21})
21export class VideoBlacklistModel extends Model { 22export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlacklistModel>>> {
22 23
23 @AllowNull(true) 24 @AllowNull(true)
24 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason', true)) 25 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason', true))
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index bfdec73e9..d24be56c3 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -15,8 +15,9 @@ import {
15 Table, 15 Table,
16 UpdatedAt 16 UpdatedAt
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { v4 as uuidv4 } from 'uuid' 18import { buildUUID } from '@server/helpers/uuid'
19import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/types/models' 19import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/types/models'
20import { AttributesOnly } from '@shared/core-utils'
20import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' 21import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
21import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' 22import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
22import { logger } from '../../helpers/logger' 23import { logger } from '../../helpers/logger'
@@ -57,7 +58,7 @@ export enum ScopeNames {
57 } 58 }
58 ] 59 ]
59}) 60})
60export class VideoCaptionModel extends Model { 61export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaptionModel>>> {
61 @CreatedAt 62 @CreatedAt
62 createdAt: Date 63 createdAt: Date
63 64
@@ -90,9 +91,9 @@ export class VideoCaptionModel extends Model {
90 Video: VideoModel 91 Video: VideoModel
91 92
92 @BeforeDestroy 93 @BeforeDestroy
93 static async removeFiles (instance: VideoCaptionModel) { 94 static async removeFiles (instance: VideoCaptionModel, options) {
94 if (!instance.Video) { 95 if (!instance.Video) {
95 instance.Video = await instance.$get('Video') 96 instance.Video = await instance.$get('Video', { transaction: options.transaction })
96 } 97 }
97 98
98 if (instance.isOwned()) { 99 if (instance.isOwned()) {
@@ -108,7 +109,7 @@ export class VideoCaptionModel extends Model {
108 return undefined 109 return undefined
109 } 110 }
110 111
111 static loadByVideoIdAndLanguage (videoId: string | number, language: string): Promise<MVideoCaptionVideo> { 112 static loadByVideoIdAndLanguage (videoId: string | number, language: string, transaction?: Transaction): Promise<MVideoCaptionVideo> {
112 const videoInclude = { 113 const videoInclude = {
113 model: VideoModel.unscoped(), 114 model: VideoModel.unscoped(),
114 attributes: [ 'id', 'remote', 'uuid' ], 115 attributes: [ 'id', 'remote', 'uuid' ],
@@ -121,7 +122,8 @@ export class VideoCaptionModel extends Model {
121 }, 122 },
122 include: [ 123 include: [
123 videoInclude 124 videoInclude
124 ] 125 ],
126 transaction
125 } 127 }
126 128
127 return VideoCaptionModel.findOne(query) 129 return VideoCaptionModel.findOne(query)
@@ -144,19 +146,21 @@ export class VideoCaptionModel extends Model {
144 } 146 }
145 147
146 static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) { 148 static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) {
147 const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language) 149 const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language, transaction)
150
148 // Delete existing file 151 // Delete existing file
149 if (existing) await existing.destroy({ transaction }) 152 if (existing) await existing.destroy({ transaction })
150 153
151 return caption.save({ transaction }) 154 return caption.save({ transaction })
152 } 155 }
153 156
154 static listVideoCaptions (videoId: number): Promise<MVideoCaptionVideo[]> { 157 static listVideoCaptions (videoId: number, transaction?: Transaction): Promise<MVideoCaptionVideo[]> {
155 const query = { 158 const query = {
156 order: [ [ 'language', 'ASC' ] ] as OrderItem[], 159 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
157 where: { 160 where: {
158 videoId 161 videoId
159 } 162 },
163 transaction
160 } 164 }
161 165
162 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query) 166 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
@@ -178,7 +182,7 @@ export class VideoCaptionModel extends Model {
178 } 182 }
179 183
180 static generateCaptionName (language: string) { 184 static generateCaptionName (language: string) {
181 return `${uuidv4()}-${language}.vtt` 185 return `${buildUUID()}-${language}.vtt`
182 } 186 }
183 187
184 isOwned () { 188 isOwned () {
@@ -210,4 +214,10 @@ export class VideoCaptionModel extends Model {
210 214
211 return this.fileUrl 215 return this.fileUrl
212 } 216 }
217
218 isEqual (this: MVideoCaption, other: MVideoCaption) {
219 if (this.fileUrl) return this.fileUrl === other.fileUrl
220
221 return this.filename === other.filename
222 }
213} 223}
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 298e8bfe2..7d20a954d 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -1,5 +1,6 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership' 2import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership'
3import { AttributesOnly } from '@shared/core-utils'
3import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' 4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
4import { AccountModel } from '../account/account' 5import { AccountModel } from '../account/account'
5import { getSort } from '../utils' 6import { getSort } from '../utils'
@@ -53,7 +54,7 @@ enum ScopeNames {
53 ] 54 ]
54 } 55 }
55})) 56}))
56export class VideoChangeOwnershipModel extends Model { 57export class VideoChangeOwnershipModel extends Model<Partial<AttributesOnly<VideoChangeOwnershipModel>>> {
57 @CreatedAt 58 @CreatedAt
58 createdAt: Date 59 createdAt: Date
59 60
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 081b21f2d..183e7448c 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -19,6 +19,7 @@ import {
19} from 'sequelize-typescript' 19} from 'sequelize-typescript'
20import { setAsUpdated } from '@server/helpers/database-utils' 20import { setAsUpdated } from '@server/helpers/database-utils'
21import { MAccountActor } from '@server/types/models' 21import { MAccountActor } from '@server/types/models'
22import { AttributesOnly } from '@shared/core-utils'
22import { ActivityPubActor } from '../../../shared/models/activitypub' 23import { ActivityPubActor } from '../../../shared/models/activitypub'
23import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos' 24import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
24import { 25import {
@@ -36,9 +37,9 @@ import {
36 MChannelSummaryFormattable 37 MChannelSummaryFormattable
37} from '../../types/models/video' 38} from '../../types/models/video'
38import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' 39import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
39import { ActorImageModel } from '../account/actor-image' 40import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
40import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 41import { ActorFollowModel } from '../actor/actor-follow'
41import { ActorFollowModel } from '../activitypub/actor-follow' 42import { ActorImageModel } from '../actor/actor-image'
42import { ServerModel } from '../server/server' 43import { ServerModel } from '../server/server'
43import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 44import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
44import { VideoModel } from './video' 45import { VideoModel } from './video'
@@ -246,7 +247,7 @@ export type SummaryOptions = {
246 } 247 }
247 ] 248 ]
248}) 249})
249export class VideoChannelModel extends Model { 250export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannelModel>>> {
250 251
251 @AllowNull(false) 252 @AllowNull(false)
252 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name')) 253 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
@@ -290,8 +291,7 @@ export class VideoChannelModel extends Model {
290 @BelongsTo(() => AccountModel, { 291 @BelongsTo(() => AccountModel, {
291 foreignKey: { 292 foreignKey: {
292 allowNull: false 293 allowNull: false
293 }, 294 }
294 hooks: true
295 }) 295 })
296 Account: AccountModel 296 Account: AccountModel
297 297
@@ -433,8 +433,8 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
433 sort: string 433 sort: string
434 }) { 434 }) {
435 const attributesInclude = [] 435 const attributesInclude = []
436 const escapedSearch = VideoModel.sequelize.escape(options.search) 436 const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
437 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') 437 const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
438 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search)) 438 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
439 439
440 const query = { 440 const query = {
@@ -521,10 +521,10 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
521 }) 521 })
522 } 522 }
523 523
524 static loadAndPopulateAccount (id: number): Promise<MChannelBannerAccountDefault> { 524 static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
525 return VideoChannelModel.unscoped() 525 return VideoChannelModel.unscoped()
526 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ]) 526 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
527 .findByPk(id) 527 .findByPk(id, { transaction })
528 } 528 }
529 529
530 static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> { 530 static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index 151c2bc81..e933989ae 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -16,10 +16,11 @@ import {
16} from 'sequelize-typescript' 16} from 'sequelize-typescript'
17import { getServerActor } from '@server/models/application/application' 17import { getServerActor } from '@server/models/application/application'
18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
19import { AttributesOnly } from '@shared/core-utils'
19import { VideoPrivacy } from '@shared/models' 20import { VideoPrivacy } from '@shared/models'
20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 21import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 22import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
22import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/video-comment.model' 23import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model'
23import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' 24import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
24import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 25import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
25import { regexpCapture } from '../../helpers/regexp' 26import { regexpCapture } from '../../helpers/regexp'
@@ -39,7 +40,7 @@ import {
39} from '../../types/models/video' 40} from '../../types/models/video'
40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 41import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
41import { AccountModel } from '../account/account' 42import { AccountModel } from '../account/account'
42import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 43import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
43import { 44import {
44 buildBlockedAccountSQL, 45 buildBlockedAccountSQL,
45 buildBlockedAccountSQLOptimized, 46 buildBlockedAccountSQLOptimized,
@@ -68,14 +69,10 @@ export enum ScopeNames {
68 Sequelize.literal( 69 Sequelize.literal(
69 '(' + 70 '(' +
70 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' + 71 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
71 'SELECT COUNT("replies"."id") - (' + 72 'SELECT COUNT("replies"."id") ' +
72 'SELECT COUNT("replies"."id") ' +
73 'FROM "videoComment" AS "replies" ' +
74 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
75 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
76 ')' +
77 'FROM "videoComment" AS "replies" ' + 73 'FROM "videoComment" AS "replies" ' +
78 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' + 74 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
75 'AND "deletedAt" IS NULL ' +
79 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' + 76 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
80 ')' 77 ')'
81 ), 78 ),
@@ -173,7 +170,7 @@ export enum ScopeNames {
173 } 170 }
174 ] 171 ]
175}) 172})
176export class VideoCommentModel extends Model { 173export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoCommentModel>>> {
177 @CreatedAt 174 @CreatedAt
178 createdAt: Date 175 createdAt: Date
179 176
@@ -742,6 +739,12 @@ export class VideoCommentModel extends Model {
742 return this.Account.isOwned() 739 return this.Account.isOwned()
743 } 740 }
744 741
742 markAsDeleted () {
743 this.text = ''
744 this.deletedAt = new Date()
745 this.accountId = null
746 }
747
745 isDeleted () { 748 isDeleted () {
746 return this.deletedAt !== null 749 return this.deletedAt !== null
747 } 750 }
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 0b5946149..22cf63804 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -25,6 +25,7 @@ import { logger } from '@server/helpers/logger'
25import { extractVideo } from '@server/helpers/video' 25import { extractVideo } from '@server/helpers/video'
26import { getTorrentFilePath } from '@server/lib/video-paths' 26import { getTorrentFilePath } from '@server/lib/video-paths'
27import { MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models' 27import { MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models'
28import { AttributesOnly } from '@shared/core-utils'
28import { 29import {
29 isVideoFileExtnameValid, 30 isVideoFileExtnameValid,
30 isVideoFileInfoHashValid, 31 isVideoFileInfoHashValid,
@@ -149,7 +150,7 @@ export enum ScopeNames {
149 } 150 }
150 ] 151 ]
151}) 152})
152export class VideoFileModel extends Model { 153export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>>> {
153 @CreatedAt 154 @CreatedAt
154 createdAt: Date 155 createdAt: Date
155 156
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index 8324166cc..5c73fb07c 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -13,15 +13,16 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { afterCommitIfTransaction } from '@server/helpers/database-utils'
16import { MVideoImportDefault, MVideoImportFormattable } from '@server/types/models/video/video-import' 17import { MVideoImportDefault, MVideoImportFormattable } from '@server/types/models/video/video-import'
18import { AttributesOnly } from '@shared/core-utils'
17import { VideoImport, VideoImportState } from '../../../shared' 19import { VideoImport, VideoImportState } from '../../../shared'
18import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports' 20import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
19import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' 21import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
20import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' 22import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
21import { UserModel } from '../account/user' 23import { UserModel } from '../user/user'
22import { getSort, throwIfNotValid } from '../utils' 24import { getSort, throwIfNotValid } from '../utils'
23import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 25import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
24import { afterCommitIfTransaction } from '@server/helpers/database-utils'
25 26
26@DefaultScope(() => ({ 27@DefaultScope(() => ({
27 include: [ 28 include: [
@@ -52,7 +53,7 @@ import { afterCommitIfTransaction } from '@server/helpers/database-utils'
52 } 53 }
53 ] 54 ]
54}) 55})
55export class VideoImportModel extends Model { 56export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportModel>>> {
56 @CreatedAt 57 @CreatedAt
57 createdAt: Date 58 createdAt: Date
58 59
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
index cb4a9b896..014491d50 100644
--- a/server/models/video/video-live.ts
+++ b/server/models/video/video-live.ts
@@ -1,6 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { WEBSERVER } from '@server/initializers/constants' 2import { WEBSERVER } from '@server/initializers/constants'
3import { MVideoLive, MVideoLiveVideo } from '@server/types/models' 3import { MVideoLive, MVideoLiveVideo } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { LiveVideo, VideoState } from '@shared/models' 5import { LiveVideo, VideoState } from '@shared/models'
5import { VideoModel } from './video' 6import { VideoModel } from './video'
6import { VideoBlacklistModel } from './video-blacklist' 7import { VideoBlacklistModel } from './video-blacklist'
@@ -28,7 +29,7 @@ import { VideoBlacklistModel } from './video-blacklist'
28 } 29 }
29 ] 30 ]
30}) 31})
31export class VideoLiveModel extends Model { 32export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>>> {
32 33
33 @AllowNull(true) 34 @AllowNull(true)
34 @Column(DataType.STRING) 35 @Column(DataType.STRING)
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index d2d7e2740..e6906cb19 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -32,6 +32,7 @@ import { AccountModel } from '../account/account'
32import { getSort, throwIfNotValid } from '../utils' 32import { getSort, throwIfNotValid } from '../utils'
33import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' 33import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
34import { VideoPlaylistModel } from './video-playlist' 34import { VideoPlaylistModel } from './video-playlist'
35import { AttributesOnly } from '@shared/core-utils'
35 36
36@Table({ 37@Table({
37 tableName: 'videoPlaylistElement', 38 tableName: 'videoPlaylistElement',
@@ -48,7 +49,7 @@ import { VideoPlaylistModel } from './video-playlist'
48 } 49 }
49 ] 50 ]
50}) 51})
51export class VideoPlaylistElementModel extends Model { 52export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<VideoPlaylistElementModel>>> {
52 @CreatedAt 53 @CreatedAt
53 createdAt: Date 54 createdAt: Date
54 55
@@ -274,7 +275,8 @@ export class VideoPlaylistElementModel extends Model {
274 validate: false // We use a literal to update the position 275 validate: false // We use a literal to update the position
275 } 276 }
276 277
277 return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query) 278 const positionQuery = Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`)
279 return VideoPlaylistElementModel.update({ position: positionQuery as any }, query)
278 } 280 }
279 281
280 static increasePositionOf ( 282 static increasePositionOf (
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index efe5be36d..af81c9906 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -1,5 +1,5 @@
1import { join } from 'path' 1import { join } from 'path'
2import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize' 2import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
3import { 3import {
4 AllowNull, 4 AllowNull,
5 BelongsTo, 5 BelongsTo,
@@ -17,8 +17,10 @@ import {
17 Table, 17 Table,
18 UpdatedAt 18 UpdatedAt
19} from 'sequelize-typescript' 19} from 'sequelize-typescript'
20import { v4 as uuidv4 } from 'uuid' 20import { setAsUpdated } from '@server/helpers/database-utils'
21import { buildUUID, uuidToShort } from '@server/helpers/uuid'
21import { MAccountId, MChannelId } from '@server/types/models' 22import { MAccountId, MChannelId } from '@server/types/models'
23import { AttributesOnly } from '@shared/core-utils'
22import { ActivityIconObject } from '../../../shared/models/activitypub/objects' 24import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
23import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' 25import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
24import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' 26import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
@@ -50,11 +52,19 @@ import {
50 MVideoPlaylistIdWithElements 52 MVideoPlaylistIdWithElements
51} from '../../types/models/video/video-playlist' 53} from '../../types/models/video/video-playlist'
52import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' 54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
53import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils' 55import { ActorModel } from '../actor/actor'
56import {
57 buildServerIdsFollowedBy,
58 buildTrigramSearchIndex,
59 buildWhereIdOrUUID,
60 createSimilarityAttribute,
61 getPlaylistSort,
62 isOutdated,
63 throwIfNotValid
64} from '../utils'
54import { ThumbnailModel } from './thumbnail' 65import { ThumbnailModel } from './thumbnail'
55import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 66import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
56import { VideoPlaylistElementModel } from './video-playlist-element' 67import { VideoPlaylistElementModel } from './video-playlist-element'
57import { ActorModel } from '../activitypub/actor'
58 68
59enum ScopeNames { 69enum ScopeNames {
60 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 70 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
@@ -72,6 +82,11 @@ type AvailableForListOptions = {
72 videoChannelId?: number 82 videoChannelId?: number
73 listMyPlaylists?: boolean 83 listMyPlaylists?: boolean
74 search?: string 84 search?: string
85 withVideos?: boolean
86}
87
88function getVideoLengthSelect () {
89 return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
75} 90}
76 91
77@Scopes(() => ({ 92@Scopes(() => ({
@@ -87,7 +102,7 @@ type AvailableForListOptions = {
87 attributes: { 102 attributes: {
88 include: [ 103 include: [
89 [ 104 [
90 literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), 105 literal(`(${getVideoLengthSelect()})`),
91 'videosLength' 106 'videosLength'
92 ] 107 ]
93 ] 108 ]
@@ -176,11 +191,28 @@ type AvailableForListOptions = {
176 }) 191 })
177 } 192 }
178 193
194 if (options.withVideos === true) {
195 whereAnd.push(
196 literal(`(${getVideoLengthSelect()}) != 0`)
197 )
198 }
199
200 const attributesInclude = []
201
179 if (options.search) { 202 if (options.search) {
203 const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
204 const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
205 attributesInclude.push(createSimilarityAttribute('VideoPlaylistModel.name', options.search))
206
180 whereAnd.push({ 207 whereAnd.push({
181 name: { 208 [Op.or]: [
182 [Op.iLike]: '%' + options.search + '%' 209 Sequelize.literal(
183 } 210 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
211 ),
212 Sequelize.literal(
213 'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
214 )
215 ]
184 }) 216 })
185 } 217 }
186 218
@@ -189,6 +221,9 @@ type AvailableForListOptions = {
189 } 221 }
190 222
191 return { 223 return {
224 attributes: {
225 include: attributesInclude
226 },
192 where, 227 where,
193 include: [ 228 include: [
194 { 229 {
@@ -209,6 +244,8 @@ type AvailableForListOptions = {
209@Table({ 244@Table({
210 tableName: 'videoPlaylist', 245 tableName: 'videoPlaylist',
211 indexes: [ 246 indexes: [
247 buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
248
212 { 249 {
213 fields: [ 'ownerAccountId' ] 250 fields: [ 'ownerAccountId' ]
214 }, 251 },
@@ -221,7 +258,7 @@ type AvailableForListOptions = {
221 } 258 }
222 ] 259 ]
223}) 260})
224export class VideoPlaylistModel extends Model { 261export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlaylistModel>>> {
225 @CreatedAt 262 @CreatedAt
226 createdAt: Date 263 createdAt: Date
227 264
@@ -312,6 +349,7 @@ export class VideoPlaylistModel extends Model {
312 videoChannelId?: number 349 videoChannelId?: number
313 listMyPlaylists?: boolean 350 listMyPlaylists?: boolean
314 search?: string 351 search?: string
352 withVideos?: boolean // false by default
315 }) { 353 }) {
316 const query = { 354 const query = {
317 offset: options.start, 355 offset: options.start,
@@ -329,7 +367,8 @@ export class VideoPlaylistModel extends Model {
329 accountId: options.accountId, 367 accountId: options.accountId,
330 videoChannelId: options.videoChannelId, 368 videoChannelId: options.videoChannelId,
331 listMyPlaylists: options.listMyPlaylists, 369 listMyPlaylists: options.listMyPlaylists,
332 search: options.search 370 search: options.search,
371 withVideos: options.withVideos || false
333 } as AvailableForListOptions 372 } as AvailableForListOptions
334 ] 373 ]
335 }, 374 },
@@ -345,6 +384,21 @@ export class VideoPlaylistModel extends Model {
345 }) 384 })
346 } 385 }
347 386
387 static searchForApi (options: {
388 followerActorId: number
389 start: number
390 count: number
391 sort: string
392 search?: string
393 }) {
394 return VideoPlaylistModel.listForApi({
395 ...options,
396 type: VideoPlaylistType.REGULAR,
397 listMyPlaylists: false,
398 withVideos: true
399 })
400 }
401
348 static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) { 402 static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
349 const where = { 403 const where = {
350 privacy: VideoPlaylistPrivacy.PUBLIC 404 privacy: VideoPlaylistPrivacy.PUBLIC
@@ -443,6 +497,18 @@ export class VideoPlaylistModel extends Model {
443 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query) 497 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
444 } 498 }
445 499
500 static loadByUrlWithAccountAndChannelSummary (url: string): Promise<MVideoPlaylistFullSummary> {
501 const query = {
502 where: {
503 url
504 }
505 }
506
507 return VideoPlaylistModel
508 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
509 .findOne(query)
510 }
511
446 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { 512 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
447 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' 513 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
448 } 514 }
@@ -479,7 +545,7 @@ export class VideoPlaylistModel extends Model {
479 generateThumbnailName () { 545 generateThumbnailName () {
480 const extension = '.jpg' 546 const extension = '.jpg'
481 547
482 return 'playlist-' + uuidv4() + extension 548 return 'playlist-' + buildUUID() + extension
483 } 549 }
484 550
485 getThumbnailUrl () { 551 getThumbnailUrl () {
@@ -495,7 +561,7 @@ export class VideoPlaylistModel extends Model {
495 } 561 }
496 562
497 getWatchUrl () { 563 getWatchUrl () {
498 return WEBSERVER.URL + '/videos/watch/playlist/' + this.uuid 564 return WEBSERVER.URL + '/w/p/' + this.uuid
499 } 565 }
500 566
501 getEmbedStaticPath () { 567 getEmbedStaticPath () {
@@ -530,9 +596,11 @@ export class VideoPlaylistModel extends Model {
530 } 596 }
531 597
532 setAsRefreshed () { 598 setAsRefreshed () {
533 this.changed('updatedAt', true) 599 return setAsUpdated('videoPlaylist', this.id)
600 }
534 601
535 return this.save() 602 setVideosLength (videosLength: number) {
603 this.set('videosLength' as any, videosLength, { raw: true })
536 } 604 }
537 605
538 isOwned () { 606 isOwned () {
@@ -549,8 +617,12 @@ export class VideoPlaylistModel extends Model {
549 return { 617 return {
550 id: this.id, 618 id: this.id,
551 uuid: this.uuid, 619 uuid: this.uuid,
620 shortUUID: uuidToShort(this.uuid),
621
552 isLocal: this.isOwned(), 622 isLocal: this.isOwned(),
553 623
624 url: this.url,
625
554 displayName: this.name, 626 displayName: this.name,
555 description: this.description, 627 description: this.description,
556 privacy: { 628 privacy: {
diff --git a/server/models/video/video-query-builder.ts b/server/models/video/video-query-builder.ts
deleted file mode 100644
index 155afe64b..000000000
--- a/server/models/video/video-query-builder.ts
+++ /dev/null
@@ -1,599 +0,0 @@
1import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models'
2import { buildDirectionAndField, createSafeIn } from '@server/models/utils'
3import { Model } from 'sequelize-typescript'
4import { MUserAccountId, MUserId } from '@server/types/models'
5import validator from 'validator'
6import { exists } from '@server/helpers/custom-validators/misc'
7
8export type BuildVideosQueryOptions = {
9 attributes?: string[]
10
11 serverAccountId: number
12 followerActorId: number
13 includeLocalVideos: boolean
14
15 count: number
16 start: number
17 sort: string
18
19 nsfw?: boolean
20 filter?: VideoFilter
21 isLive?: boolean
22
23 categoryOneOf?: number[]
24 licenceOneOf?: number[]
25 languageOneOf?: string[]
26 tagsOneOf?: string[]
27 tagsAllOf?: string[]
28
29 withFiles?: boolean
30
31 accountId?: number
32 videoChannelId?: number
33
34 videoPlaylistId?: number
35
36 trendingAlgorithm?: string // best, hot, or any other algorithm implemented
37 trendingDays?: number
38
39 user?: MUserAccountId
40 historyOfUser?: MUserId
41
42 startDate?: string // ISO 8601
43 endDate?: string // ISO 8601
44 originallyPublishedStartDate?: string
45 originallyPublishedEndDate?: string
46
47 durationMin?: number // seconds
48 durationMax?: number // seconds
49
50 search?: string
51
52 isCount?: boolean
53
54 group?: string
55 having?: string
56}
57
58function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) {
59 const and: string[] = []
60 const joins: string[] = []
61 const replacements: any = {}
62 const cte: string[] = []
63
64 let attributes: string[] = options.attributes || [ '"video"."id"' ]
65 let group = options.group || ''
66 const having = options.having || ''
67
68 joins.push(
69 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"' +
70 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"' +
71 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"'
72 )
73
74 and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
75
76 if (options.serverAccountId) {
77 const blockerIds = [ options.serverAccountId ]
78 if (options.user) blockerIds.push(options.user.Account.id)
79
80 const inClause = createSafeIn(model, blockerIds)
81
82 and.push(
83 'NOT EXISTS (' +
84 ' SELECT 1 FROM "accountBlocklist" ' +
85 ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' +
86 ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' +
87 ')' +
88 'AND NOT EXISTS (' +
89 ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' +
90 ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' +
91 ')'
92 )
93 }
94
95 // Only list public/published videos
96 if (!options.filter || (options.filter !== 'all-local' && options.filter !== 'all')) {
97 and.push(
98 `("video"."state" = ${VideoState.PUBLISHED} OR ` +
99 `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
100 )
101
102 if (options.user) {
103 and.push(
104 `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
105 )
106 } else { // Or only public videos
107 and.push(
108 `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
109 )
110 }
111 }
112
113 if (options.videoPlaylistId) {
114 joins.push(
115 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
116 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
117 )
118
119 replacements.videoPlaylistId = options.videoPlaylistId
120 }
121
122 if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) {
123 and.push('"video"."remote" IS FALSE')
124 }
125
126 if (options.accountId) {
127 and.push('"account"."id" = :accountId')
128 replacements.accountId = options.accountId
129 }
130
131 if (options.videoChannelId) {
132 and.push('"videoChannel"."id" = :videoChannelId')
133 replacements.videoChannelId = options.videoChannelId
134 }
135
136 if (options.followerActorId) {
137 let query =
138 '(' +
139 ' EXISTS (' +
140 ' SELECT 1 FROM "videoShare" ' +
141 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
142 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
143 ' WHERE "videoShare"."videoId" = "video"."id"' +
144 ' )' +
145 ' OR' +
146 ' EXISTS (' +
147 ' SELECT 1 from "actorFollow" ' +
148 ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
149 ' AND "actorFollow"."state" = \'accepted\'' +
150 ' )'
151
152 if (options.includeLocalVideos) {
153 query += ' OR "video"."remote" IS FALSE'
154 }
155
156 query += ')'
157
158 and.push(query)
159 replacements.followerActorId = options.followerActorId
160 }
161
162 if (options.withFiles === true) {
163 and.push(
164 '(' +
165 ' EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id") ' +
166 ' OR EXISTS (' +
167 ' SELECT 1 FROM "videoStreamingPlaylist" ' +
168 ' INNER JOIN "videoFile" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ' +
169 ' WHERE "videoStreamingPlaylist"."videoId" = "video"."id"' +
170 ' )' +
171 ')'
172 )
173 }
174
175 if (options.tagsOneOf) {
176 const tagsOneOfLower = options.tagsOneOf.map(t => t.toLowerCase())
177
178 and.push(
179 'EXISTS (' +
180 ' SELECT 1 FROM "videoTag" ' +
181 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
182 ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsOneOfLower) + ') ' +
183 ' AND "video"."id" = "videoTag"."videoId"' +
184 ')'
185 )
186 }
187
188 if (options.tagsAllOf) {
189 const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase())
190
191 and.push(
192 'EXISTS (' +
193 ' SELECT 1 FROM "videoTag" ' +
194 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
195 ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsAllOfLower) + ') ' +
196 ' AND "video"."id" = "videoTag"."videoId" ' +
197 ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
198 ')'
199 )
200 }
201
202 if (options.nsfw === true) {
203 and.push('"video"."nsfw" IS TRUE')
204 } else if (options.nsfw === false) {
205 and.push('"video"."nsfw" IS FALSE')
206 }
207
208 if (options.isLive === true) {
209 and.push('"video"."isLive" IS TRUE')
210 } else if (options.isLive === false) {
211 and.push('"video"."isLive" IS FALSE')
212 }
213
214 if (options.categoryOneOf) {
215 and.push('"video"."category" IN (:categoryOneOf)')
216 replacements.categoryOneOf = options.categoryOneOf
217 }
218
219 if (options.licenceOneOf) {
220 and.push('"video"."licence" IN (:licenceOneOf)')
221 replacements.licenceOneOf = options.licenceOneOf
222 }
223
224 if (options.languageOneOf) {
225 const languages = options.languageOneOf.filter(l => l && l !== '_unknown')
226 const languagesQueryParts: string[] = []
227
228 if (languages.length !== 0) {
229 languagesQueryParts.push('"video"."language" IN (:languageOneOf)')
230 replacements.languageOneOf = languages
231
232 languagesQueryParts.push(
233 'EXISTS (' +
234 ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
235 ' IN (' + createSafeIn(model, languages) + ') AND ' +
236 ' "videoCaption"."videoId" = "video"."id"' +
237 ')'
238 )
239 }
240
241 if (options.languageOneOf.includes('_unknown')) {
242 languagesQueryParts.push('"video"."language" IS NULL')
243 }
244
245 if (languagesQueryParts.length !== 0) {
246 and.push('(' + languagesQueryParts.join(' OR ') + ')')
247 }
248 }
249
250 // We don't exclude results in this so if we do a count we don't need to add this complex clause
251 if (options.isCount !== true) {
252 if (options.trendingDays) {
253 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays)
254
255 joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
256 replacements.viewsGteDate = viewsGteDate
257
258 attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
259
260 group = 'GROUP BY "video"."id"'
261 } else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) {
262 /**
263 * "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
264 * with fixed weights only applied to their log values.
265 *
266 * This algorithm gives little chance for an old video to have a good score,
267 * for which recent spikes in interactions could be a sign of "hotness" and
268 * justify a better score. However there are multiple ways to achieve that
269 * goal, which is left for later. Yes, this is a TODO :)
270 *
271 * notes:
272 * - weights and base score are in number of half-days.
273 * - all comments are counted, regardless of being written by the video author or not
274 * see https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L47-L58
275 * - we have less interactions than on reddit, so multiply weights by an arbitrary factor
276 */
277 const weights = {
278 like: 3 * 50,
279 dislike: -3 * 50,
280 view: Math.floor((1 / 3) * 50),
281 comment: 2 * 50, // a comment takes more time than a like to do, but can be done multiple times
282 history: -2 * 50
283 }
284
285 joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
286
287 let attribute =
288 `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
289 `+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
290 `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
291 `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
292 '+ (SELECT (EXTRACT(epoch FROM "video"."publishedAt") - 1446156582) / 47000) ' // base score (in number of half-days)
293
294 if (options.trendingAlgorithm === 'best' && options.user) {
295 joins.push(
296 'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser'
297 )
298 replacements.bestUser = options.user.id
299
300 attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} `
301 }
302
303 attribute += 'AS "score"'
304 attributes.push(attribute)
305
306 group = 'GROUP BY "video"."id"'
307 }
308 }
309
310 if (options.historyOfUser) {
311 joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"')
312
313 and.push('"userVideoHistory"."userId" = :historyOfUser')
314 replacements.historyOfUser = options.historyOfUser.id
315 }
316
317 if (options.startDate) {
318 and.push('"video"."publishedAt" >= :startDate')
319 replacements.startDate = options.startDate
320 }
321
322 if (options.endDate) {
323 and.push('"video"."publishedAt" <= :endDate')
324 replacements.endDate = options.endDate
325 }
326
327 if (options.originallyPublishedStartDate) {
328 and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
329 replacements.originallyPublishedStartDate = options.originallyPublishedStartDate
330 }
331
332 if (options.originallyPublishedEndDate) {
333 and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
334 replacements.originallyPublishedEndDate = options.originallyPublishedEndDate
335 }
336
337 if (options.durationMin) {
338 and.push('"video"."duration" >= :durationMin')
339 replacements.durationMin = options.durationMin
340 }
341
342 if (options.durationMax) {
343 and.push('"video"."duration" <= :durationMax')
344 replacements.durationMax = options.durationMax
345 }
346
347 if (options.search) {
348 const escapedSearch = model.sequelize.escape(options.search)
349 const escapedLikeSearch = model.sequelize.escape('%' + options.search + '%')
350
351 cte.push(
352 '"trigramSearch" AS (' +
353 ' SELECT "video"."id", ' +
354 ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` +
355 ' FROM "video" ' +
356 ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
357 ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
358 ')'
359 )
360
361 joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
362
363 let base = '(' +
364 ' "trigramSearch"."id" IS NOT NULL OR ' +
365 ' EXISTS (' +
366 ' SELECT 1 FROM "videoTag" ' +
367 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
368 ` WHERE lower("tag"."name") = ${escapedSearch} ` +
369 ' AND "video"."id" = "videoTag"."videoId"' +
370 ' )'
371
372 if (validator.isUUID(options.search)) {
373 base += ` OR "video"."uuid" = ${escapedSearch}`
374 }
375
376 base += ')'
377 and.push(base)
378
379 attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`)
380 } else {
381 attributes.push('0 as similarity')
382 }
383
384 if (options.isCount === true) attributes = [ 'COUNT(*) as "total"' ]
385
386 let suffix = ''
387 let order = ''
388 if (options.isCount !== true) {
389
390 if (exists(options.sort)) {
391 if (options.sort === '-originallyPublishedAt' || options.sort === 'originallyPublishedAt') {
392 attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
393 }
394
395 order = buildOrder(options.sort)
396 suffix += `${order} `
397 }
398
399 if (exists(options.count)) {
400 const count = parseInt(options.count + '', 10)
401 suffix += `LIMIT ${count} `
402 }
403
404 if (exists(options.start)) {
405 const start = parseInt(options.start + '', 10)
406 suffix += `OFFSET ${start} `
407 }
408 }
409
410 const cteString = cte.length !== 0
411 ? `WITH ${cte.join(', ')} `
412 : ''
413
414 const query = cteString +
415 'SELECT ' + attributes.join(', ') + ' ' +
416 'FROM "video" ' + joins.join(' ') + ' ' +
417 'WHERE ' + and.join(' AND ') + ' ' +
418 group + ' ' +
419 having + ' ' +
420 suffix
421
422 return { query, replacements, order }
423}
424
425function buildOrder (value: string) {
426 const { direction, field } = buildDirectionAndField(value)
427 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
428
429 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
430
431 if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
432 return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
433 }
434
435 let firstSort: string
436
437 if (field.toLowerCase() === 'match') { // Search
438 firstSort = '"similarity"'
439 } else if (field === 'originallyPublishedAt') {
440 firstSort = '"publishedAtForOrder"'
441 } else if (field.includes('.')) {
442 firstSort = field
443 } else {
444 firstSort = `"video"."${field}"`
445 }
446
447 return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
448}
449
450function wrapForAPIResults (baseQuery: string, replacements: any, options: BuildVideosQueryOptions, order: string) {
451 const attributes = {
452 '"video".*': '',
453 '"VideoChannel"."id"': '"VideoChannel.id"',
454 '"VideoChannel"."name"': '"VideoChannel.name"',
455 '"VideoChannel"."description"': '"VideoChannel.description"',
456 '"VideoChannel"."actorId"': '"VideoChannel.actorId"',
457 '"VideoChannel->Actor"."id"': '"VideoChannel.Actor.id"',
458 '"VideoChannel->Actor"."preferredUsername"': '"VideoChannel.Actor.preferredUsername"',
459 '"VideoChannel->Actor"."url"': '"VideoChannel.Actor.url"',
460 '"VideoChannel->Actor"."serverId"': '"VideoChannel.Actor.serverId"',
461 '"VideoChannel->Actor"."avatarId"': '"VideoChannel.Actor.avatarId"',
462 '"VideoChannel->Account"."id"': '"VideoChannel.Account.id"',
463 '"VideoChannel->Account"."name"': '"VideoChannel.Account.name"',
464 '"VideoChannel->Account->Actor"."id"': '"VideoChannel.Account.Actor.id"',
465 '"VideoChannel->Account->Actor"."preferredUsername"': '"VideoChannel.Account.Actor.preferredUsername"',
466 '"VideoChannel->Account->Actor"."url"': '"VideoChannel.Account.Actor.url"',
467 '"VideoChannel->Account->Actor"."serverId"': '"VideoChannel.Account.Actor.serverId"',
468 '"VideoChannel->Account->Actor"."avatarId"': '"VideoChannel.Account.Actor.avatarId"',
469 '"VideoChannel->Actor->Server"."id"': '"VideoChannel.Actor.Server.id"',
470 '"VideoChannel->Actor->Server"."host"': '"VideoChannel.Actor.Server.host"',
471 '"VideoChannel->Actor->Avatar"."id"': '"VideoChannel.Actor.Avatar.id"',
472 '"VideoChannel->Actor->Avatar"."filename"': '"VideoChannel.Actor.Avatar.filename"',
473 '"VideoChannel->Actor->Avatar"."fileUrl"': '"VideoChannel.Actor.Avatar.fileUrl"',
474 '"VideoChannel->Actor->Avatar"."onDisk"': '"VideoChannel.Actor.Avatar.onDisk"',
475 '"VideoChannel->Actor->Avatar"."createdAt"': '"VideoChannel.Actor.Avatar.createdAt"',
476 '"VideoChannel->Actor->Avatar"."updatedAt"': '"VideoChannel.Actor.Avatar.updatedAt"',
477 '"VideoChannel->Account->Actor->Server"."id"': '"VideoChannel.Account.Actor.Server.id"',
478 '"VideoChannel->Account->Actor->Server"."host"': '"VideoChannel.Account.Actor.Server.host"',
479 '"VideoChannel->Account->Actor->Avatar"."id"': '"VideoChannel.Account.Actor.Avatar.id"',
480 '"VideoChannel->Account->Actor->Avatar"."filename"': '"VideoChannel.Account.Actor.Avatar.filename"',
481 '"VideoChannel->Account->Actor->Avatar"."fileUrl"': '"VideoChannel.Account.Actor.Avatar.fileUrl"',
482 '"VideoChannel->Account->Actor->Avatar"."onDisk"': '"VideoChannel.Account.Actor.Avatar.onDisk"',
483 '"VideoChannel->Account->Actor->Avatar"."createdAt"': '"VideoChannel.Account.Actor.Avatar.createdAt"',
484 '"VideoChannel->Account->Actor->Avatar"."updatedAt"': '"VideoChannel.Account.Actor.Avatar.updatedAt"',
485 '"Thumbnails"."id"': '"Thumbnails.id"',
486 '"Thumbnails"."type"': '"Thumbnails.type"',
487 '"Thumbnails"."filename"': '"Thumbnails.filename"'
488 }
489
490 const joins = [
491 'INNER JOIN "video" ON "tmp"."id" = "video"."id"',
492
493 'INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"',
494 'INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"',
495 'INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"',
496 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"',
497
498 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"',
499 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatar" ' +
500 'ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"',
501
502 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' +
503 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"',
504
505 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatar" ' +
506 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"',
507
508 'LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"'
509 ]
510
511 if (options.withFiles) {
512 joins.push('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"')
513
514 joins.push('LEFT JOIN "videoStreamingPlaylist" AS "VideoStreamingPlaylists" ON "VideoStreamingPlaylists"."videoId" = "video"."id"')
515 joins.push(
516 'LEFT JOIN "videoFile" AS "VideoStreamingPlaylists->VideoFiles" ' +
517 'ON "VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId" = "VideoStreamingPlaylists"."id"'
518 )
519
520 Object.assign(attributes, {
521 '"VideoFiles"."id"': '"VideoFiles.id"',
522 '"VideoFiles"."createdAt"': '"VideoFiles.createdAt"',
523 '"VideoFiles"."updatedAt"': '"VideoFiles.updatedAt"',
524 '"VideoFiles"."resolution"': '"VideoFiles.resolution"',
525 '"VideoFiles"."size"': '"VideoFiles.size"',
526 '"VideoFiles"."extname"': '"VideoFiles.extname"',
527 '"VideoFiles"."filename"': '"VideoFiles.filename"',
528 '"VideoFiles"."fileUrl"': '"VideoFiles.fileUrl"',
529 '"VideoFiles"."torrentFilename"': '"VideoFiles.torrentFilename"',
530 '"VideoFiles"."torrentUrl"': '"VideoFiles.torrentUrl"',
531 '"VideoFiles"."infoHash"': '"VideoFiles.infoHash"',
532 '"VideoFiles"."fps"': '"VideoFiles.fps"',
533 '"VideoFiles"."videoId"': '"VideoFiles.videoId"',
534
535 '"VideoStreamingPlaylists"."id"': '"VideoStreamingPlaylists.id"',
536 '"VideoStreamingPlaylists"."playlistUrl"': '"VideoStreamingPlaylists.playlistUrl"',
537 '"VideoStreamingPlaylists"."type"': '"VideoStreamingPlaylists.type"',
538 '"VideoStreamingPlaylists->VideoFiles"."id"': '"VideoStreamingPlaylists.VideoFiles.id"',
539 '"VideoStreamingPlaylists->VideoFiles"."createdAt"': '"VideoStreamingPlaylists.VideoFiles.createdAt"',
540 '"VideoStreamingPlaylists->VideoFiles"."updatedAt"': '"VideoStreamingPlaylists.VideoFiles.updatedAt"',
541 '"VideoStreamingPlaylists->VideoFiles"."resolution"': '"VideoStreamingPlaylists.VideoFiles.resolution"',
542 '"VideoStreamingPlaylists->VideoFiles"."size"': '"VideoStreamingPlaylists.VideoFiles.size"',
543 '"VideoStreamingPlaylists->VideoFiles"."extname"': '"VideoStreamingPlaylists.VideoFiles.extname"',
544 '"VideoStreamingPlaylists->VideoFiles"."filename"': '"VideoStreamingPlaylists.VideoFiles.filename"',
545 '"VideoStreamingPlaylists->VideoFiles"."fileUrl"': '"VideoStreamingPlaylists.VideoFiles.fileUrl"',
546 '"VideoStreamingPlaylists->VideoFiles"."torrentFilename"': '"VideoStreamingPlaylists.VideoFiles.torrentFilename"',
547 '"VideoStreamingPlaylists->VideoFiles"."torrentUrl"': '"VideoStreamingPlaylists.VideoFiles.torrentUrl"',
548 '"VideoStreamingPlaylists->VideoFiles"."infoHash"': '"VideoStreamingPlaylists.VideoFiles.infoHash"',
549 '"VideoStreamingPlaylists->VideoFiles"."fps"': '"VideoStreamingPlaylists.VideoFiles.fps"',
550 '"VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId"': '"VideoStreamingPlaylists.VideoFiles.videoStreamingPlaylistId"',
551 '"VideoStreamingPlaylists->VideoFiles"."videoId"': '"VideoStreamingPlaylists.VideoFiles.videoId"'
552 })
553 }
554
555 if (options.user) {
556 joins.push(
557 'LEFT OUTER JOIN "userVideoHistory" ' +
558 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId'
559 )
560 replacements.userVideoHistoryId = options.user.id
561
562 Object.assign(attributes, {
563 '"userVideoHistory"."id"': '"userVideoHistory.id"',
564 '"userVideoHistory"."currentTime"': '"userVideoHistory.currentTime"'
565 })
566 }
567
568 if (options.videoPlaylistId) {
569 joins.push(
570 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
571 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
572 )
573 replacements.videoPlaylistId = options.videoPlaylistId
574
575 Object.assign(attributes, {
576 '"VideoPlaylistElement"."createdAt"': '"VideoPlaylistElement.createdAt"',
577 '"VideoPlaylistElement"."updatedAt"': '"VideoPlaylistElement.updatedAt"',
578 '"VideoPlaylistElement"."url"': '"VideoPlaylistElement.url"',
579 '"VideoPlaylistElement"."position"': '"VideoPlaylistElement.position"',
580 '"VideoPlaylistElement"."startTimestamp"': '"VideoPlaylistElement.startTimestamp"',
581 '"VideoPlaylistElement"."stopTimestamp"': '"VideoPlaylistElement.stopTimestamp"',
582 '"VideoPlaylistElement"."videoPlaylistId"': '"VideoPlaylistElement.videoPlaylistId"'
583 })
584 }
585
586 const select = 'SELECT ' + Object.keys(attributes).map(key => {
587 const value = attributes[key]
588 if (value) return `${key} AS ${value}`
589
590 return key
591 }).join(', ')
592
593 return `${select} FROM (${baseQuery}) AS "tmp" ${joins.join(' ')} ${order}`
594}
595
596export {
597 buildListQuery,
598 wrapForAPIResults
599}
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index 5059c1fa6..505c305e2 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -1,10 +1,11 @@
1import { literal, Op, QueryTypes, Transaction } from 'sequelize' 1import { literal, Op, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { AttributesOnly } from '@shared/core-utils'
3import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 4import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
4import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 5import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
5import { MActorDefault } from '../../types/models' 6import { MActorDefault } from '../../types/models'
6import { MVideoShareActor, MVideoShareFull } from '../../types/models/video' 7import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
7import { ActorModel } from '../activitypub/actor' 8import { ActorModel } from '../actor/actor'
8import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' 9import { buildLocalActorIdsIn, throwIfNotValid } from '../utils'
9import { VideoModel } from './video' 10import { VideoModel } from './video'
10 11
@@ -50,7 +51,7 @@ enum ScopeNames {
50 } 51 }
51 ] 52 ]
52}) 53})
53export class VideoShareModel extends Model { 54export class VideoShareModel extends Model<Partial<AttributesOnly<VideoShareModel>>> {
54 55
55 @AllowNull(false) 56 @AllowNull(false)
56 @Is('VideoShareUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) 57 @Is('VideoShareUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index c9375b433..d627e8c9d 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -13,6 +13,7 @@ import { CONSTRAINTS_FIELDS, MEMOIZE_LENGTH, MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_
13import { VideoRedundancyModel } from '../redundancy/video-redundancy' 13import { VideoRedundancyModel } from '../redundancy/video-redundancy'
14import { throwIfNotValid } from '../utils' 14import { throwIfNotValid } from '../utils'
15import { VideoModel } from './video' 15import { VideoModel } from './video'
16import { AttributesOnly } from '@shared/core-utils'
16 17
17@Table({ 18@Table({
18 tableName: 'videoStreamingPlaylist', 19 tableName: 'videoStreamingPlaylist',
@@ -30,7 +31,7 @@ import { VideoModel } from './video'
30 } 31 }
31 ] 32 ]
32}) 33})
33export class VideoStreamingPlaylistModel extends Model { 34export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<VideoStreamingPlaylistModel>>> {
34 @CreatedAt 35 @CreatedAt
35 createdAt: Date 36 createdAt: Date
36 37
diff --git a/server/models/video/video-tag.ts b/server/models/video/video-tag.ts
index 5052b8c4d..1285d375b 100644
--- a/server/models/video/video-tag.ts
+++ b/server/models/video/video-tag.ts
@@ -1,4 +1,5 @@
1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { TagModel } from './tag' 3import { TagModel } from './tag'
3import { VideoModel } from './video' 4import { VideoModel } from './video'
4 5
@@ -13,7 +14,7 @@ import { VideoModel } from './video'
13 } 14 }
14 ] 15 ]
15}) 16})
16export class VideoTagModel extends Model { 17export class VideoTagModel extends Model<Partial<AttributesOnly<VideoTagModel>>> {
17 @CreatedAt 18 @CreatedAt
18 createdAt: Date 19 createdAt: Date
19 20
diff --git a/server/models/video/video-view.ts b/server/models/video/video-view.ts
index 992cf258a..dfc6296ce 100644
--- a/server/models/video/video-view.ts
+++ b/server/models/video/video-view.ts
@@ -1,6 +1,7 @@
1import * as Sequelize from 'sequelize'
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript'
3import { AttributesOnly } from '@shared/core-utils'
2import { VideoModel } from './video' 4import { VideoModel } from './video'
3import * as Sequelize from 'sequelize'
4 5
5@Table({ 6@Table({
6 tableName: 'videoView', 7 tableName: 'videoView',
@@ -14,7 +15,7 @@ import * as Sequelize from 'sequelize'
14 } 15 }
15 ] 16 ]
16}) 17})
17export class VideoViewModel extends Model { 18export class VideoViewModel extends Model<Partial<AttributesOnly<VideoViewModel>>> {
18 @CreatedAt 19 @CreatedAt
19 createdAt: Date 20 createdAt: Date
20 21
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 18afba1ba..1e5648a36 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1,6 +1,6 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { remove } from 'fs-extra' 2import { remove } from 'fs-extra'
3import { maxBy, minBy, pick } from 'lodash' 3import { maxBy, minBy } from 'lodash'
4import { join } from 'path' 4import { join } from 'path'
5import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 5import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
6import { 6import {
@@ -27,10 +27,11 @@ import {
27import { setAsUpdated } from '@server/helpers/database-utils' 27import { setAsUpdated } from '@server/helpers/database-utils'
28import { buildNSFWFilter } from '@server/helpers/express-utils' 28import { buildNSFWFilter } from '@server/helpers/express-utils'
29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' 29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30import { LiveManager } from '@server/lib/live-manager' 30import { LiveManager } from '@server/lib/live/live-manager'
31import { getHLSDirectory, getVideoFilePath } from '@server/lib/video-paths' 31import { getHLSDirectory, getVideoFilePath } from '@server/lib/video-paths'
32import { getServerActor } from '@server/models/application/application' 32import { getServerActor } from '@server/models/application/application'
33import { ModelCache } from '@server/models/model-cache' 33import { ModelCache } from '@server/models/model-cache'
34import { AttributesOnly } from '@shared/core-utils'
34import { VideoFile } from '@shared/models/videos/video-file.model' 35import { VideoFile } from '@shared/models/videos/video-file.model'
35import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared' 36import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
36import { VideoObject } from '../../../shared/models/activitypub/objects' 37import { VideoObject } from '../../../shared/models/activitypub/objects'
@@ -42,11 +43,8 @@ import { peertubeTruncate } from '../../helpers/core-utils'
42import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 43import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
43import { isBooleanValid } from '../../helpers/custom-validators/misc' 44import { isBooleanValid } from '../../helpers/custom-validators/misc'
44import { 45import {
45 isVideoCategoryValid,
46 isVideoDescriptionValid, 46 isVideoDescriptionValid,
47 isVideoDurationValid, 47 isVideoDurationValid,
48 isVideoLanguageValid,
49 isVideoLicenceValid,
50 isVideoNameValid, 48 isVideoNameValid,
51 isVideoPrivacyValid, 49 isVideoPrivacyValid,
52 isVideoStateValid, 50 isVideoStateValid,
@@ -55,19 +53,7 @@ import {
55import { getVideoFileResolution } from '../../helpers/ffprobe-utils' 53import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
56import { logger } from '../../helpers/logger' 54import { logger } from '../../helpers/logger'
57import { CONFIG } from '../../initializers/config' 55import { CONFIG } from '../../initializers/config'
58import { 56import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
59 ACTIVITY_PUB,
60 API_VERSION,
61 CONSTRAINTS_FIELDS,
62 LAZY_STATIC_PATHS,
63 STATIC_PATHS,
64 VIDEO_CATEGORIES,
65 VIDEO_LANGUAGES,
66 VIDEO_LICENCES,
67 VIDEO_PRIVACIES,
68 VIDEO_STATES,
69 WEBSERVER
70} from '../../initializers/constants'
71import { sendDeleteVideo } from '../../lib/activitypub/send' 57import { sendDeleteVideo } from '../../lib/activitypub/send'
72import { 58import {
73 MChannel, 59 MChannel,
@@ -87,29 +73,38 @@ import {
87 MVideoFormattableDetails, 73 MVideoFormattableDetails,
88 MVideoForUser, 74 MVideoForUser,
89 MVideoFullLight, 75 MVideoFullLight,
90 MVideoIdThumbnail, 76 MVideoId,
91 MVideoImmutable, 77 MVideoImmutable,
92 MVideoThumbnail, 78 MVideoThumbnail,
93 MVideoThumbnailBlacklist, 79 MVideoThumbnailBlacklist,
94 MVideoWithAllFiles, 80 MVideoWithAllFiles,
95 MVideoWithFile, 81 MVideoWithFile
96 MVideoWithRights
97} from '../../types/models' 82} from '../../types/models'
98import { MThumbnail } from '../../types/models/video/thumbnail' 83import { MThumbnail } from '../../types/models/video/thumbnail'
99import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file' 84import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
100import { VideoAbuseModel } from '../abuse/video-abuse' 85import { VideoAbuseModel } from '../abuse/video-abuse'
101import { AccountModel } from '../account/account' 86import { AccountModel } from '../account/account'
102import { AccountVideoRateModel } from '../account/account-video-rate' 87import { AccountVideoRateModel } from '../account/account-video-rate'
103import { ActorImageModel } from '../account/actor-image' 88import { ActorModel } from '../actor/actor'
104import { UserModel } from '../account/user' 89import { ActorImageModel } from '../actor/actor-image'
105import { UserVideoHistoryModel } from '../account/user-video-history'
106import { ActorModel } from '../activitypub/actor'
107import { VideoRedundancyModel } from '../redundancy/video-redundancy' 90import { VideoRedundancyModel } from '../redundancy/video-redundancy'
108import { ServerModel } from '../server/server' 91import { ServerModel } from '../server/server'
109import { TrackerModel } from '../server/tracker' 92import { TrackerModel } from '../server/tracker'
110import { VideoTrackerModel } from '../server/video-tracker' 93import { VideoTrackerModel } from '../server/video-tracker'
94import { UserModel } from '../user/user'
95import { UserVideoHistoryModel } from '../user/user-video-history'
111import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' 96import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
97import {
98 videoFilesModelToFormattedJSON,
99 VideoFormattingJSONOptions,
100 videoModelToActivityPubObject,
101 videoModelToFormattedDetailsJSON,
102 videoModelToFormattedJSON
103} from './formatter/video-format-utils'
112import { ScheduleVideoUpdateModel } from './schedule-video-update' 104import { ScheduleVideoUpdateModel } from './schedule-video-update'
105import { VideosModelGetQueryBuilder } from './sql/video-model-get-query-builder'
106import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder'
107import { VideosModelListQueryBuilder } from './sql/videos-model-list-query-builder'
113import { TagModel } from './tag' 108import { TagModel } from './tag'
114import { ThumbnailModel } from './thumbnail' 109import { ThumbnailModel } from './thumbnail'
115import { VideoBlacklistModel } from './video-blacklist' 110import { VideoBlacklistModel } from './video-blacklist'
@@ -117,37 +112,25 @@ import { VideoCaptionModel } from './video-caption'
117import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 112import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
118import { VideoCommentModel } from './video-comment' 113import { VideoCommentModel } from './video-comment'
119import { VideoFileModel } from './video-file' 114import { VideoFileModel } from './video-file'
120import {
121 videoFilesModelToFormattedJSON,
122 VideoFormattingJSONOptions,
123 videoModelToActivityPubObject,
124 videoModelToFormattedDetailsJSON,
125 videoModelToFormattedJSON
126} from './video-format-utils'
127import { VideoImportModel } from './video-import' 115import { VideoImportModel } from './video-import'
128import { VideoLiveModel } from './video-live' 116import { VideoLiveModel } from './video-live'
129import { VideoPlaylistElementModel } from './video-playlist-element' 117import { VideoPlaylistElementModel } from './video-playlist-element'
130import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder'
131import { VideoShareModel } from './video-share' 118import { VideoShareModel } from './video-share'
132import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 119import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
133import { VideoTagModel } from './video-tag' 120import { VideoTagModel } from './video-tag'
134import { VideoViewModel } from './video-view' 121import { VideoViewModel } from './video-view'
135 122
136export enum ScopeNames { 123export enum ScopeNames {
137 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
138 FOR_API = 'FOR_API', 124 FOR_API = 'FOR_API',
139 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', 125 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
140 WITH_TAGS = 'WITH_TAGS', 126 WITH_TAGS = 'WITH_TAGS',
141 WITH_TRACKERS = 'WITH_TRACKERS',
142 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES', 127 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES',
143 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 128 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
144 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 129 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
145 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
146 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', 130 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
147 WITH_USER_ID = 'WITH_USER_ID',
148 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES', 131 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
149 WITH_THUMBNAILS = 'WITH_THUMBNAILS', 132 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
150 WITH_LIVE = 'WITH_LIVE' 133 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
151} 134}
152 135
153export type ForAPIOptions = { 136export type ForAPIOptions = {
@@ -243,30 +226,6 @@ export type AvailableForListIDsOptions = {
243 } 226 }
244 ] 227 ]
245 }, 228 },
246 [ScopeNames.WITH_LIVE]: {
247 include: [
248 {
249 model: VideoLiveModel.unscoped(),
250 required: false
251 }
252 ]
253 },
254 [ScopeNames.WITH_USER_ID]: {
255 include: [
256 {
257 attributes: [ 'accountId' ],
258 model: VideoChannelModel.unscoped(),
259 required: true,
260 include: [
261 {
262 attributes: [ 'userId' ],
263 model: AccountModel.unscoped(),
264 required: true
265 }
266 ]
267 }
268 ]
269 },
270 [ScopeNames.WITH_ACCOUNT_DETAILS]: { 229 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
271 include: [ 230 include: [
272 { 231 {
@@ -324,14 +283,6 @@ export type AvailableForListIDsOptions = {
324 [ScopeNames.WITH_TAGS]: { 283 [ScopeNames.WITH_TAGS]: {
325 include: [ TagModel ] 284 include: [ TagModel ]
326 }, 285 },
327 [ScopeNames.WITH_TRACKERS]: {
328 include: [
329 {
330 attributes: [ 'id', 'url' ],
331 model: TrackerModel
332 }
333 ]
334 },
335 [ScopeNames.WITH_BLACKLISTED]: { 286 [ScopeNames.WITH_BLACKLISTED]: {
336 include: [ 287 include: [
337 { 288 {
@@ -489,7 +440,7 @@ export type AvailableForListIDsOptions = {
489 } 440 }
490 ] 441 ]
491}) 442})
492export class VideoModel extends Model { 443export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
493 444
494 @AllowNull(false) 445 @AllowNull(false)
495 @Default(DataType.UUIDV4) 446 @Default(DataType.UUIDV4)
@@ -504,19 +455,16 @@ export class VideoModel extends Model {
504 455
505 @AllowNull(true) 456 @AllowNull(true)
506 @Default(null) 457 @Default(null)
507 @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category', true))
508 @Column 458 @Column
509 category: number 459 category: number
510 460
511 @AllowNull(true) 461 @AllowNull(true)
512 @Default(null) 462 @Default(null)
513 @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence', true))
514 @Column 463 @Column
515 licence: number 464 licence: number
516 465
517 @AllowNull(true) 466 @AllowNull(true)
518 @Default(null) 467 @Default(null)
519 @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language', true))
520 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max)) 468 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
521 language: string 469 language: string
522 470
@@ -624,7 +572,7 @@ export class VideoModel extends Model {
624 foreignKey: { 572 foreignKey: {
625 allowNull: true 573 allowNull: true
626 }, 574 },
627 hooks: true 575 onDelete: 'cascade'
628 }) 576 })
629 VideoChannel: VideoChannelModel 577 VideoChannel: VideoChannelModel
630 578
@@ -802,14 +750,14 @@ export class VideoModel extends Model {
802 } 750 }
803 751
804 @BeforeDestroy 752 @BeforeDestroy
805 static async removeFiles (instance: VideoModel) { 753 static async removeFiles (instance: VideoModel, options) {
806 const tasks: Promise<any>[] = [] 754 const tasks: Promise<any>[] = []
807 755
808 logger.info('Removing files of video %s.', instance.url) 756 logger.info('Removing files of video %s.', instance.url)
809 757
810 if (instance.isOwned()) { 758 if (instance.isOwned()) {
811 if (!Array.isArray(instance.VideoFiles)) { 759 if (!Array.isArray(instance.VideoFiles)) {
812 instance.VideoFiles = await instance.$get('VideoFiles') 760 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
813 } 761 }
814 762
815 // Remove physical files and torrents 763 // Remove physical files and torrents
@@ -820,7 +768,7 @@ export class VideoModel extends Model {
820 768
821 // Remove playlists file 769 // Remove playlists file
822 if (!Array.isArray(instance.VideoStreamingPlaylists)) { 770 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
823 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists') 771 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
824 } 772 }
825 773
826 for (const p of instance.VideoStreamingPlaylists) { 774 for (const p of instance.VideoStreamingPlaylists) {
@@ -843,7 +791,7 @@ export class VideoModel extends Model {
843 791
844 logger.info('Stopping live of video %s after video deletion.', instance.uuid) 792 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
845 793
846 return LiveManager.Instance.stopSessionOf(instance.id) 794 LiveManager.Instance.stopSessionOf(instance.id)
847 } 795 }
848 796
849 @BeforeDestroy 797 @BeforeDestroy
@@ -856,7 +804,7 @@ export class VideoModel extends Model {
856 const tasks: Promise<any>[] = [] 804 const tasks: Promise<any>[] = []
857 805
858 if (!Array.isArray(instance.VideoAbuses)) { 806 if (!Array.isArray(instance.VideoAbuses)) {
859 instance.VideoAbuses = await instance.$get('VideoAbuses') 807 instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
860 808
861 if (instance.VideoAbuses.length === 0) return undefined 809 if (instance.VideoAbuses.length === 0) return undefined
862 } 810 }
@@ -871,12 +819,7 @@ export class VideoModel extends Model {
871 tasks.push(abuse.save({ transaction: options.transaction })) 819 tasks.push(abuse.save({ transaction: options.transaction }))
872 } 820 }
873 821
874 Promise.all(tasks) 822 await Promise.all(tasks)
875 .catch(err => {
876 logger.error('Some errors when saving details of video %s in its abuses before destroy hook.', instance.uuid, { err })
877 })
878
879 return undefined
880 } 823 }
881 824
882 static listLocal (): Promise<MVideo[]> { 825 static listLocal (): Promise<MVideo[]> {
@@ -1003,9 +946,9 @@ export class VideoModel extends Model {
1003 }) 946 })
1004 } 947 }
1005 948
1006 static async listPublishedLiveIds () { 949 static async listPublishedLiveUUIDs () {
1007 const options = { 950 const options = {
1008 attributes: [ 'id' ], 951 attributes: [ 'uuid' ],
1009 where: { 952 where: {
1010 isLive: true, 953 isLive: true,
1011 remote: false, 954 remote: false,
@@ -1015,7 +958,7 @@ export class VideoModel extends Model {
1015 958
1016 const result = await VideoModel.findAll(options) 959 const result = await VideoModel.findAll(options)
1017 960
1018 return result.map(v => v.id) 961 return result.map(v => v.uuid)
1019 } 962 }
1020 963
1021 static listUserVideosForApi (options: { 964 static listUserVideosForApi (options: {
@@ -1298,27 +1241,16 @@ export class VideoModel extends Model {
1298 return VideoModel.count(options) 1241 return VideoModel.count(options)
1299 } 1242 }
1300 1243
1301 static load (id: number | string, t?: Transaction): Promise<MVideoThumbnail> { 1244 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1302 const where = buildWhereIdOrUUID(id) 1245 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1303 const options = {
1304 where,
1305 transaction: t
1306 }
1307 1246
1308 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options) 1247 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1309 } 1248 }
1310 1249
1311 static loadWithBlacklist (id: number | string, t?: Transaction): Promise<MVideoThumbnailBlacklist> { 1250 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1312 const where = buildWhereIdOrUUID(id) 1251 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1313 const options = {
1314 where,
1315 transaction: t
1316 }
1317 1252
1318 return VideoModel.scope([ 1253 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1319 ScopeNames.WITH_THUMBNAILS,
1320 ScopeNames.WITH_BLACKLISTED
1321 ]).findOne(options)
1322 } 1254 }
1323 1255
1324 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> { 1256 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
@@ -1339,68 +1271,6 @@ export class VideoModel extends Model {
1339 }) 1271 })
1340 } 1272 }
1341 1273
1342 static loadWithRights (id: number | string, t?: Transaction): Promise<MVideoWithRights> {
1343 const where = buildWhereIdOrUUID(id)
1344 const options = {
1345 where,
1346 transaction: t
1347 }
1348
1349 return VideoModel.scope([
1350 ScopeNames.WITH_BLACKLISTED,
1351 ScopeNames.WITH_USER_ID
1352 ]).findOne(options)
1353 }
1354
1355 static loadOnlyId (id: number | string, t?: Transaction): Promise<MVideoIdThumbnail> {
1356 const where = buildWhereIdOrUUID(id)
1357
1358 const options = {
1359 attributes: [ 'id' ],
1360 where,
1361 transaction: t
1362 }
1363
1364 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1365 }
1366
1367 static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1368 const where = buildWhereIdOrUUID(id)
1369
1370 const query = {
1371 where,
1372 transaction: t,
1373 logging
1374 }
1375
1376 return VideoModel.scope([
1377 ScopeNames.WITH_WEBTORRENT_FILES,
1378 ScopeNames.WITH_STREAMING_PLAYLISTS,
1379 ScopeNames.WITH_THUMBNAILS
1380 ]).findOne(query)
1381 }
1382
1383 static loadByUUID (uuid: string): Promise<MVideoThumbnail> {
1384 const options = {
1385 where: {
1386 uuid
1387 }
1388 }
1389
1390 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1391 }
1392
1393 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1394 const query: FindOptions = {
1395 where: {
1396 url
1397 },
1398 transaction
1399 }
1400
1401 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1402 }
1403
1404 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> { 1274 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1405 const fun = () => { 1275 const fun = () => {
1406 const query: FindOptions = { 1276 const query: FindOptions = {
@@ -1421,85 +1291,45 @@ export class VideoModel extends Model {
1421 }) 1291 })
1422 } 1292 }
1423 1293
1424 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> { 1294 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1425 const query: FindOptions = { 1295 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1426 where: {
1427 url
1428 },
1429 transaction
1430 }
1431 1296
1432 return VideoModel.scope([ 1297 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1433 ScopeNames.WITH_ACCOUNT_DETAILS,
1434 ScopeNames.WITH_WEBTORRENT_FILES,
1435 ScopeNames.WITH_STREAMING_PLAYLISTS,
1436 ScopeNames.WITH_THUMBNAILS,
1437 ScopeNames.WITH_BLACKLISTED
1438 ]).findOne(query)
1439 } 1298 }
1440 1299
1441 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> { 1300 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1442 const where = buildWhereIdOrUUID(id) 1301 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1443 1302
1444 const options = { 1303 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1445 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, 1304 }
1446 where,
1447 transaction: t
1448 }
1449 1305
1450 const scopes: (string | ScopeOptions)[] = [ 1306 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1451 ScopeNames.WITH_TAGS, 1307 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1452 ScopeNames.WITH_BLACKLISTED,
1453 ScopeNames.WITH_ACCOUNT_DETAILS,
1454 ScopeNames.WITH_SCHEDULED_UPDATE,
1455 ScopeNames.WITH_WEBTORRENT_FILES,
1456 ScopeNames.WITH_STREAMING_PLAYLISTS,
1457 ScopeNames.WITH_THUMBNAILS,
1458 ScopeNames.WITH_LIVE
1459 ]
1460 1308
1461 if (userId) { 1309 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1462 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] }) 1310 }
1463 } 1311
1312 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1313 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1314
1315 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1316 }
1464 1317
1465 return VideoModel 1318 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1466 .scope(scopes) 1319 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1467 .findOne(options) 1320
1321 return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId })
1468 } 1322 }
1469 1323
1470 static loadForGetAPI (parameters: { 1324 static loadForGetAPI (parameters: {
1471 id: number | string 1325 id: number | string
1472 t?: Transaction 1326 transaction?: Transaction
1473 userId?: number 1327 userId?: number
1474 }): Promise<MVideoDetails> { 1328 }): Promise<MVideoDetails> {
1475 const { id, t, userId } = parameters 1329 const { id, transaction, userId } = parameters
1476 const where = buildWhereIdOrUUID(id) 1330 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1477 1331
1478 const options = { 1332 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1479 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1480 where,
1481 transaction: t
1482 }
1483
1484 const scopes: (string | ScopeOptions)[] = [
1485 ScopeNames.WITH_TAGS,
1486 ScopeNames.WITH_BLACKLISTED,
1487 ScopeNames.WITH_ACCOUNT_DETAILS,
1488 ScopeNames.WITH_SCHEDULED_UPDATE,
1489 ScopeNames.WITH_THUMBNAILS,
1490 ScopeNames.WITH_LIVE,
1491 ScopeNames.WITH_TRACKERS,
1492 { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
1493 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1494 ]
1495
1496 if (userId) {
1497 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1498 }
1499
1500 return VideoModel
1501 .scope(scopes)
1502 .findOne(options)
1503 } 1333 }
1504 1334
1505 static async getStats () { 1335 static async getStats () {
@@ -1550,7 +1380,7 @@ export class VideoModel extends Model {
1550 1380
1551 const rawQuery = `UPDATE "video" SET "${field}" = ` + 1381 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1552 '(' + 1382 '(' +
1553 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' + 1383 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1554 ') ' + 1384 ') ' +
1555 'WHERE "video"."id" = :videoId' 1385 'WHERE "video"."id" = :videoId'
1556 1386
@@ -1578,15 +1408,15 @@ export class VideoModel extends Model {
1578 .then(results => results.length === 1) 1408 .then(results => results.length === 1)
1579 } 1409 }
1580 1410
1581 static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) { 1411 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1582 const options = { 1412 const options = {
1583 where: { 1413 where: {
1584 channelId: videoChannel.id 1414 channelId: ofChannel.id
1585 }, 1415 },
1586 transaction: t 1416 transaction: t
1587 } 1417 }
1588 1418
1589 return VideoModel.update({ support: videoChannel.support }, options) 1419 return VideoModel.update({ support: ofChannel.support }, options)
1590 } 1420 }
1591 1421
1592 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> { 1422 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
@@ -1606,7 +1436,7 @@ export class VideoModel extends Model {
1606 const serverActor = await getServerActor() 1436 const serverActor = await getServerActor()
1607 const followerActorId = serverActor.id 1437 const followerActorId = serverActor.id
1608 1438
1609 const queryOptions: BuildVideosQueryOptions = { 1439 const queryOptions: BuildVideosListQueryOptions = {
1610 attributes: [ `"${field}"` ], 1440 attributes: [ `"${field}"` ],
1611 group: `GROUP BY "${field}"`, 1441 group: `GROUP BY "${field}"`,
1612 having: `HAVING COUNT("${field}") >= ${threshold}`, 1442 having: `HAVING COUNT("${field}") >= ${threshold}`,
@@ -1618,10 +1448,10 @@ export class VideoModel extends Model {
1618 includeLocalVideos: true 1448 includeLocalVideos: true
1619 } 1449 }
1620 1450
1621 const { query, replacements } = buildListQuery(VideoModel, queryOptions) 1451 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1622 1452
1623 return this.sequelize.query<any>(query, { replacements, type: QueryTypes.SELECT }) 1453 return queryBuilder.queryVideoIds(queryOptions)
1624 .then(rows => rows.map(r => r[field])) 1454 .then(rows => rows.map(r => r[field]))
1625 } 1455 }
1626 1456
1627 static buildTrendingQuery (trendingDays: number) { 1457 static buildTrendingQuery (trendingDays: number) {
@@ -1639,27 +1469,24 @@ export class VideoModel extends Model {
1639 } 1469 }
1640 1470
1641 private static async getAvailableForApi ( 1471 private static async getAvailableForApi (
1642 options: BuildVideosQueryOptions, 1472 options: BuildVideosListQueryOptions,
1643 countVideos = true 1473 countVideos = true
1644 ): Promise<ResultList<VideoModel>> { 1474 ): Promise<ResultList<VideoModel>> {
1645 function getCount () { 1475 function getCount () {
1646 if (countVideos !== true) return Promise.resolve(undefined) 1476 if (countVideos !== true) return Promise.resolve(undefined)
1647 1477
1648 const countOptions = Object.assign({}, options, { isCount: true }) 1478 const countOptions = Object.assign({}, options, { isCount: true })
1649 const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel, countOptions) 1479 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1650 1480
1651 return VideoModel.sequelize.query<any>(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT }) 1481 return queryBuilder.countVideoIds(countOptions)
1652 .then(rows => rows.length !== 0 ? rows[0].total : 0)
1653 } 1482 }
1654 1483
1655 function getModels () { 1484 function getModels () {
1656 if (options.count === 0) return Promise.resolve([]) 1485 if (options.count === 0) return Promise.resolve([])
1657 1486
1658 const { query, replacements, order } = buildListQuery(VideoModel, options) 1487 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1659 const queryModels = wrapForAPIResults(query, replacements, options, order)
1660 1488
1661 return VideoModel.sequelize.query<any>(queryModels, { replacements, type: QueryTypes.SELECT, nest: true }) 1489 return queryBuilder.queryVideos(options)
1662 .then(rows => VideoModel.buildAPIResult(rows))
1663 } 1490 }
1664 1491
1665 const [ count, rows ] = await Promise.all([ getCount(), getModels() ]) 1492 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
@@ -1670,173 +1497,6 @@ export class VideoModel extends Model {
1670 } 1497 }
1671 } 1498 }
1672 1499
1673 private static buildAPIResult (rows: any[]) {
1674 const videosMemo: { [ id: number ]: VideoModel } = {}
1675 const videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel } = {}
1676
1677 const thumbnailsDone = new Set<number>()
1678 const historyDone = new Set<number>()
1679 const videoFilesDone = new Set<number>()
1680
1681 const videos: VideoModel[] = []
1682
1683 const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ]
1684 const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ]
1685 const serverKeys = [ 'id', 'host' ]
1686 const videoFileKeys = [
1687 'id',
1688 'createdAt',
1689 'updatedAt',
1690 'resolution',
1691 'size',
1692 'extname',
1693 'filename',
1694 'fileUrl',
1695 'torrentFilename',
1696 'torrentUrl',
1697 'infoHash',
1698 'fps',
1699 'videoId',
1700 'videoStreamingPlaylistId'
1701 ]
1702 const videoStreamingPlaylistKeys = [ 'id', 'type', 'playlistUrl' ]
1703 const videoKeys = [
1704 'id',
1705 'uuid',
1706 'name',
1707 'category',
1708 'licence',
1709 'language',
1710 'privacy',
1711 'nsfw',
1712 'description',
1713 'support',
1714 'duration',
1715 'views',
1716 'likes',
1717 'dislikes',
1718 'remote',
1719 'isLive',
1720 'url',
1721 'commentsEnabled',
1722 'downloadEnabled',
1723 'waitTranscoding',
1724 'state',
1725 'publishedAt',
1726 'originallyPublishedAt',
1727 'channelId',
1728 'createdAt',
1729 'updatedAt'
1730 ]
1731 const buildOpts = { raw: true }
1732
1733 function buildActor (rowActor: any) {
1734 const avatarModel = rowActor.Avatar.id !== null
1735 ? new ActorImageModel(pick(rowActor.Avatar, avatarKeys), buildOpts)
1736 : null
1737
1738 const serverModel = rowActor.Server.id !== null
1739 ? new ServerModel(pick(rowActor.Server, serverKeys), buildOpts)
1740 : null
1741
1742 const actorModel = new ActorModel(pick(rowActor, actorKeys), buildOpts)
1743 actorModel.Avatar = avatarModel
1744 actorModel.Server = serverModel
1745
1746 return actorModel
1747 }
1748
1749 for (const row of rows) {
1750 if (!videosMemo[row.id]) {
1751 // Build Channel
1752 const channel = row.VideoChannel
1753 const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ]), buildOpts)
1754 channelModel.Actor = buildActor(channel.Actor)
1755
1756 const account = row.VideoChannel.Account
1757 const accountModel = new AccountModel(pick(account, [ 'id', 'name' ]), buildOpts)
1758 accountModel.Actor = buildActor(account.Actor)
1759
1760 channelModel.Account = accountModel
1761
1762 const videoModel = new VideoModel(pick(row, videoKeys), buildOpts)
1763 videoModel.VideoChannel = channelModel
1764
1765 videoModel.UserVideoHistories = []
1766 videoModel.Thumbnails = []
1767 videoModel.VideoFiles = []
1768 videoModel.VideoStreamingPlaylists = []
1769
1770 videosMemo[row.id] = videoModel
1771 // Don't take object value to have a sorted array
1772 videos.push(videoModel)
1773 }
1774
1775 const videoModel = videosMemo[row.id]
1776
1777 if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) {
1778 const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ]), buildOpts)
1779 videoModel.UserVideoHistories.push(historyModel)
1780
1781 historyDone.add(row.userVideoHistory.id)
1782 }
1783
1784 if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) {
1785 const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ]), buildOpts)
1786 videoModel.Thumbnails.push(thumbnailModel)
1787
1788 thumbnailsDone.add(row.Thumbnails.id)
1789 }
1790
1791 if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) {
1792 const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys), buildOpts)
1793 videoModel.VideoFiles.push(videoFileModel)
1794
1795 videoFilesDone.add(row.VideoFiles.id)
1796 }
1797
1798 if (row.VideoStreamingPlaylists?.id && !videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]) {
1799 const streamingPlaylist = new VideoStreamingPlaylistModel(pick(row.VideoStreamingPlaylists, videoStreamingPlaylistKeys), buildOpts)
1800 streamingPlaylist.VideoFiles = []
1801
1802 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
1803
1804 videoStreamingPlaylistMemo[streamingPlaylist.id] = streamingPlaylist
1805 }
1806
1807 if (row.VideoStreamingPlaylists?.VideoFiles?.id && !videoFilesDone.has(row.VideoStreamingPlaylists.VideoFiles.id)) {
1808 const streamingPlaylist = videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]
1809
1810 const videoFileModel = new VideoFileModel(pick(row.VideoStreamingPlaylists.VideoFiles, videoFileKeys), buildOpts)
1811 streamingPlaylist.VideoFiles.push(videoFileModel)
1812
1813 videoFilesDone.add(row.VideoStreamingPlaylists.VideoFiles.id)
1814 }
1815 }
1816
1817 return videos
1818 }
1819
1820 static getCategoryLabel (id: number) {
1821 return VIDEO_CATEGORIES[id] || 'Misc'
1822 }
1823
1824 static getLicenceLabel (id: number) {
1825 return VIDEO_LICENCES[id] || 'Unknown'
1826 }
1827
1828 static getLanguageLabel (id: string) {
1829 return VIDEO_LANGUAGES[id] || 'Unknown'
1830 }
1831
1832 static getPrivacyLabel (id: number) {
1833 return VIDEO_PRIVACIES[id] || 'Unknown'
1834 }
1835
1836 static getStateLabel (id: number) {
1837 return VIDEO_STATES[id] || 'Unknown'
1838 }
1839
1840 isBlacklisted () { 1500 isBlacklisted () {
1841 return !!this.VideoBlacklist 1501 return !!this.VideoBlacklist
1842 } 1502 }
@@ -1885,7 +1545,7 @@ export class VideoModel extends Model {
1885 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0 1545 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1886 } 1546 }
1887 1547
1888 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) { 1548 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1889 thumbnail.videoId = this.id 1549 thumbnail.videoId = this.id
1890 1550
1891 const savedThumbnail = await thumbnail.save({ transaction }) 1551 const savedThumbnail = await thumbnail.save({ transaction })
@@ -1919,7 +1579,7 @@ export class VideoModel extends Model {
1919 } 1579 }
1920 1580
1921 getWatchStaticPath () { 1581 getWatchStaticPath () {
1922 return '/videos/watch/' + this.uuid 1582 return '/w/' + this.uuid
1923 } 1583 }
1924 1584
1925 getEmbedStaticPath () { 1585 getEmbedStaticPath () {