aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
Diffstat (limited to 'server/models')
-rw-r--r--server/models/account/account-blocklist.ts142
-rw-r--r--server/models/account/account-video-rate.ts60
-rw-r--r--server/models/account/account.ts25
-rw-r--r--server/models/account/user-notification-setting.ts150
-rw-r--r--server/models/account/user-notification.ts472
-rw-r--r--server/models/account/user-video-history.ts33
-rw-r--r--server/models/account/user.ts217
-rw-r--r--server/models/activitypub/actor-follow.ts148
-rw-r--r--server/models/activitypub/actor.ts1
-rw-r--r--server/models/avatar/avatar.ts5
-rw-r--r--server/models/oauth/oauth-token.ts6
-rw-r--r--server/models/redundancy/video-redundancy.ts158
-rw-r--r--server/models/server/server-blocklist.ts121
-rw-r--r--server/models/server/server.ts6
-rw-r--r--server/models/utils.ts22
-rw-r--r--server/models/video/video-abuse.ts24
-rw-r--r--server/models/video/video-blacklist.ts31
-rw-r--r--server/models/video/video-channel.ts25
-rw-r--r--server/models/video/video-comment.ts140
-rw-r--r--server/models/video/video-file.ts32
-rw-r--r--server/models/video/video-format-utils.ts69
-rw-r--r--server/models/video/video-import.ts4
-rw-r--r--server/models/video/video-share.ts2
-rw-r--r--server/models/video/video-streaming-playlist.ts158
-rw-r--r--server/models/video/video.ts372
25 files changed, 2151 insertions, 272 deletions
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
new file mode 100644
index 000000000..efd6ed59e
--- /dev/null
+++ b/server/models/account/account-blocklist.ts
@@ -0,0 +1,142 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { AccountModel } from './account'
3import { getSort } from '../utils'
4import { AccountBlock } from '../../../shared/models/blocklist'
5import { Op } from 'sequelize'
6
7enum ScopeNames {
8 WITH_ACCOUNTS = 'WITH_ACCOUNTS'
9}
10
11@Scopes({
12 [ScopeNames.WITH_ACCOUNTS]: {
13 include: [
14 {
15 model: () => AccountModel,
16 required: true,
17 as: 'ByAccount'
18 },
19 {
20 model: () => AccountModel,
21 required: true,
22 as: 'BlockedAccount'
23 }
24 ]
25 }
26})
27
28@Table({
29 tableName: 'accountBlocklist',
30 indexes: [
31 {
32 fields: [ 'accountId', 'targetAccountId' ],
33 unique: true
34 },
35 {
36 fields: [ 'targetAccountId' ]
37 }
38 ]
39})
40export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
41
42 @CreatedAt
43 createdAt: Date
44
45 @UpdatedAt
46 updatedAt: Date
47
48 @ForeignKey(() => AccountModel)
49 @Column
50 accountId: number
51
52 @BelongsTo(() => AccountModel, {
53 foreignKey: {
54 name: 'accountId',
55 allowNull: false
56 },
57 as: 'ByAccount',
58 onDelete: 'CASCADE'
59 })
60 ByAccount: AccountModel
61
62 @ForeignKey(() => AccountModel)
63 @Column
64 targetAccountId: number
65
66 @BelongsTo(() => AccountModel, {
67 foreignKey: {
68 name: 'targetAccountId',
69 allowNull: false
70 },
71 as: 'BlockedAccount',
72 onDelete: 'CASCADE'
73 })
74 BlockedAccount: AccountModel
75
76 static isAccountMutedBy (accountId: number, targetAccountId: number) {
77 return AccountBlocklistModel.isAccountMutedByMulti([ accountId ], targetAccountId)
78 .then(result => result[accountId])
79 }
80
81 static isAccountMutedByMulti (accountIds: number[], targetAccountId: number) {
82 const query = {
83 attributes: [ 'accountId', 'id' ],
84 where: {
85 accountId: {
86 [Op.any]: accountIds
87 },
88 targetAccountId
89 },
90 raw: true
91 }
92
93 return AccountBlocklistModel.unscoped()
94 .findAll(query)
95 .then(rows => {
96 const result: { [accountId: number]: boolean } = {}
97
98 for (const accountId of accountIds) {
99 result[accountId] = !!rows.find(r => r.accountId === accountId)
100 }
101
102 return result
103 })
104 }
105
106 static loadByAccountAndTarget (accountId: number, targetAccountId: number) {
107 const query = {
108 where: {
109 accountId,
110 targetAccountId
111 }
112 }
113
114 return AccountBlocklistModel.findOne(query)
115 }
116
117 static listForApi (accountId: number, start: number, count: number, sort: string) {
118 const query = {
119 offset: start,
120 limit: count,
121 order: getSort(sort),
122 where: {
123 accountId
124 }
125 }
126
127 return AccountBlocklistModel
128 .scope([ ScopeNames.WITH_ACCOUNTS ])
129 .findAndCountAll(query)
130 .then(({ rows, count }) => {
131 return { total: count, data: rows }
132 })
133 }
134
135 toFormattedJSON (): AccountBlock {
136 return {
137 byAccount: this.ByAccount.toFormattedJSON(),
138 blockedAccount: this.BlockedAccount.toFormattedJSON(),
139 createdAt: this.createdAt
140 }
141 }
142}
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index c99e32012..18762f0c5 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -1,12 +1,14 @@
1import { values } from 'lodash' 1import { values } from 'lodash'
2import { Transaction } from 'sequelize' 2import { Transaction } from 'sequelize'
3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { IFindOptions } from 'sequelize-typescript/lib/interfaces/IFindOptions' 4import { IFindOptions } from 'sequelize-typescript/lib/interfaces/IFindOptions'
5import { VideoRateType } from '../../../shared/models/videos' 5import { VideoRateType } from '../../../shared/models/videos'
6import { VIDEO_RATE_TYPES } from '../../initializers' 6import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers'
7import { VideoModel } from '../video/video' 7import { VideoModel } from '../video/video'
8import { AccountModel } from './account' 8import { AccountModel } from './account'
9import { ActorModel } from '../activitypub/actor' 9import { ActorModel } from '../activitypub/actor'
10import { throwIfNotValid } from '../utils'
11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10 12
11/* 13/*
12 Account rates per video. 14 Account rates per video.
@@ -26,6 +28,10 @@ import { ActorModel } from '../activitypub/actor'
26 }, 28 },
27 { 29 {
28 fields: [ 'videoId', 'type' ] 30 fields: [ 'videoId', 'type' ]
31 },
32 {
33 fields: [ 'url' ],
34 unique: true
29 } 35 }
30 ] 36 ]
31}) 37})
@@ -35,6 +41,11 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
35 @Column(DataType.ENUM(values(VIDEO_RATE_TYPES))) 41 @Column(DataType.ENUM(values(VIDEO_RATE_TYPES)))
36 type: VideoRateType 42 type: VideoRateType
37 43
44 @AllowNull(false)
45 @Is('AccountVideoRateUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
46 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_RATES.URL.max))
47 url: string
48
38 @CreatedAt 49 @CreatedAt
39 createdAt: Date 50 createdAt: Date
40 51
@@ -65,7 +76,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
65 }) 76 })
66 Account: AccountModel 77 Account: AccountModel
67 78
68 static load (accountId: number, videoId: number, transaction: Transaction) { 79 static load (accountId: number, videoId: number, transaction?: Transaction) {
69 const options: IFindOptions<AccountVideoRateModel> = { 80 const options: IFindOptions<AccountVideoRateModel> = {
70 where: { 81 where: {
71 accountId, 82 accountId,
@@ -77,6 +88,49 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
77 return AccountVideoRateModel.findOne(options) 88 return AccountVideoRateModel.findOne(options)
78 } 89 }
79 90
91 static loadLocalAndPopulateVideo (rateType: VideoRateType, accountName: string, videoId: number, transaction?: Transaction) {
92 const options: IFindOptions<AccountVideoRateModel> = {
93 where: {
94 videoId,
95 type: rateType
96 },
97 include: [
98 {
99 model: AccountModel.unscoped(),
100 required: true,
101 include: [
102 {
103 attributes: [ 'id', 'url', 'preferredUsername' ],
104 model: ActorModel.unscoped(),
105 required: true,
106 where: {
107 preferredUsername: accountName
108 }
109 }
110 ]
111 },
112 {
113 model: VideoModel.unscoped(),
114 required: true
115 }
116 ]
117 }
118 if (transaction) options.transaction = transaction
119
120 return AccountVideoRateModel.findOne(options)
121 }
122
123 static loadByUrl (url: string, transaction: Transaction) {
124 const options: IFindOptions<AccountVideoRateModel> = {
125 where: {
126 url
127 }
128 }
129 if (transaction) options.transaction = transaction
130
131 return AccountVideoRateModel.findOne(options)
132 }
133
80 static listAndCountAccountUrlsByVideoId (rateType: VideoRateType, videoId: number, start: number, count: number, t?: Transaction) { 134 static listAndCountAccountUrlsByVideoId (rateType: VideoRateType, videoId: number, start: number, count: number, t?: Transaction) {
81 const query = { 135 const query = {
82 offset: start, 136 offset: start,
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 5a237d733..84ef0b30d 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -241,6 +241,27 @@ export class AccountModel extends Model<AccountModel> {
241 }) 241 })
242 } 242 }
243 243
244 static listLocalsForSitemap (sort: string) {
245 const query = {
246 attributes: [ ],
247 offset: 0,
248 order: getSort(sort),
249 include: [
250 {
251 attributes: [ 'preferredUsername', 'serverId' ],
252 model: ActorModel.unscoped(),
253 where: {
254 serverId: null
255 }
256 }
257 ]
258 }
259
260 return AccountModel
261 .unscoped()
262 .findAll(query)
263 }
264
244 toFormattedJSON (): Account { 265 toFormattedJSON (): Account {
245 const actor = this.Actor.toFormattedJSON() 266 const actor = this.Actor.toFormattedJSON()
246 const account = { 267 const account = {
@@ -267,6 +288,10 @@ export class AccountModel extends Model<AccountModel> {
267 return this.Actor.isOwned() 288 return this.Actor.isOwned()
268 } 289 }
269 290
291 isOutdated () {
292 return this.Actor.isOutdated()
293 }
294
270 getDisplayName () { 295 getDisplayName () {
271 return this.name 296 return this.name
272 } 297 }
diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts
new file mode 100644
index 000000000..f1c3ac223
--- /dev/null
+++ b/server/models/account/user-notification-setting.ts
@@ -0,0 +1,150 @@
1import {
2 AfterDestroy,
3 AfterUpdate,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 Default,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { throwIfNotValid } from '../utils'
16import { UserModel } from './user'
17import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
19import { clearCacheByUserId } from '../../lib/oauth-model'
20
21@Table({
22 tableName: 'userNotificationSetting',
23 indexes: [
24 {
25 fields: [ 'userId' ],
26 unique: true
27 }
28 ]
29})
30export class UserNotificationSettingModel extends Model<UserNotificationSettingModel> {
31
32 @AllowNull(false)
33 @Default(null)
34 @Is(
35 'UserNotificationSettingNewVideoFromSubscription',
36 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newVideoFromSubscription')
37 )
38 @Column
39 newVideoFromSubscription: UserNotificationSettingValue
40
41 @AllowNull(false)
42 @Default(null)
43 @Is(
44 'UserNotificationSettingNewCommentOnMyVideo',
45 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newCommentOnMyVideo')
46 )
47 @Column
48 newCommentOnMyVideo: UserNotificationSettingValue
49
50 @AllowNull(false)
51 @Default(null)
52 @Is(
53 'UserNotificationSettingVideoAbuseAsModerator',
54 value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAbuseAsModerator')
55 )
56 @Column
57 videoAbuseAsModerator: UserNotificationSettingValue
58
59 @AllowNull(false)
60 @Default(null)
61 @Is(
62 'UserNotificationSettingBlacklistOnMyVideo',
63 value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
64 )
65 @Column
66 blacklistOnMyVideo: UserNotificationSettingValue
67
68 @AllowNull(false)
69 @Default(null)
70 @Is(
71 'UserNotificationSettingMyVideoPublished',
72 value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoPublished')
73 )
74 @Column
75 myVideoPublished: UserNotificationSettingValue
76
77 @AllowNull(false)
78 @Default(null)
79 @Is(
80 'UserNotificationSettingMyVideoImportFinished',
81 value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoImportFinished')
82 )
83 @Column
84 myVideoImportFinished: UserNotificationSettingValue
85
86 @AllowNull(false)
87 @Default(null)
88 @Is(
89 'UserNotificationSettingNewUserRegistration',
90 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newUserRegistration')
91 )
92 @Column
93 newUserRegistration: UserNotificationSettingValue
94
95 @AllowNull(false)
96 @Default(null)
97 @Is(
98 'UserNotificationSettingNewFollow',
99 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
100 )
101 @Column
102 newFollow: UserNotificationSettingValue
103
104 @AllowNull(false)
105 @Default(null)
106 @Is(
107 'UserNotificationSettingCommentMention',
108 value => throwIfNotValid(value, isUserNotificationSettingValid, 'commentMention')
109 )
110 @Column
111 commentMention: UserNotificationSettingValue
112
113 @ForeignKey(() => UserModel)
114 @Column
115 userId: number
116
117 @BelongsTo(() => UserModel, {
118 foreignKey: {
119 allowNull: false
120 },
121 onDelete: 'cascade'
122 })
123 User: UserModel
124
125 @CreatedAt
126 createdAt: Date
127
128 @UpdatedAt
129 updatedAt: Date
130
131 @AfterUpdate
132 @AfterDestroy
133 static removeTokenCache (instance: UserNotificationSettingModel) {
134 return clearCacheByUserId(instance.userId)
135 }
136
137 toFormattedJSON (): UserNotificationSetting {
138 return {
139 newCommentOnMyVideo: this.newCommentOnMyVideo,
140 newVideoFromSubscription: this.newVideoFromSubscription,
141 videoAbuseAsModerator: this.videoAbuseAsModerator,
142 blacklistOnMyVideo: this.blacklistOnMyVideo,
143 myVideoPublished: this.myVideoPublished,
144 myVideoImportFinished: this.myVideoImportFinished,
145 newUserRegistration: this.newUserRegistration,
146 commentMention: this.commentMention,
147 newFollow: this.newFollow
148 }
149 }
150}
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
new file mode 100644
index 000000000..6cdbb827b
--- /dev/null
+++ b/server/models/account/user-notification.ts
@@ -0,0 +1,472 @@
1import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 Default,
7 ForeignKey,
8 IFindOptions,
9 Is,
10 Model,
11 Scopes,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { UserNotification, UserNotificationType } from '../../../shared'
16import { getSort, throwIfNotValid } from '../utils'
17import { isBooleanValid } from '../../helpers/custom-validators/misc'
18import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
19import { UserModel } from './user'
20import { VideoModel } from '../video/video'
21import { VideoCommentModel } from '../video/video-comment'
22import { Op } from 'sequelize'
23import { VideoChannelModel } from '../video/video-channel'
24import { AccountModel } from './account'
25import { VideoAbuseModel } from '../video/video-abuse'
26import { VideoBlacklistModel } from '../video/video-blacklist'
27import { VideoImportModel } from '../video/video-import'
28import { ActorModel } from '../activitypub/actor'
29import { ActorFollowModel } from '../activitypub/actor-follow'
30import { AvatarModel } from '../avatar/avatar'
31import { ServerModel } from '../server/server'
32
33enum ScopeNames {
34 WITH_ALL = 'WITH_ALL'
35}
36
37function buildActorWithAvatarInclude () {
38 return {
39 attributes: [ 'preferredUsername' ],
40 model: () => ActorModel.unscoped(),
41 required: true,
42 include: [
43 {
44 attributes: [ 'filename' ],
45 model: () => AvatarModel.unscoped(),
46 required: false
47 },
48 {
49 attributes: [ 'host' ],
50 model: () => ServerModel.unscoped(),
51 required: false
52 }
53 ]
54 }
55}
56
57function buildVideoInclude (required: boolean) {
58 return {
59 attributes: [ 'id', 'uuid', 'name' ],
60 model: () => VideoModel.unscoped(),
61 required
62 }
63}
64
65function buildChannelInclude (required: boolean, withActor = false) {
66 return {
67 required,
68 attributes: [ 'id', 'name' ],
69 model: () => VideoChannelModel.unscoped(),
70 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
71 }
72}
73
74function buildAccountInclude (required: boolean, withActor = false) {
75 return {
76 required,
77 attributes: [ 'id', 'name' ],
78 model: () => AccountModel.unscoped(),
79 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
80 }
81}
82
83@Scopes({
84 [ScopeNames.WITH_ALL]: {
85 include: [
86 Object.assign(buildVideoInclude(false), {
87 include: [ buildChannelInclude(true, true) ]
88 }),
89
90 {
91 attributes: [ 'id', 'originCommentId' ],
92 model: () => VideoCommentModel.unscoped(),
93 required: false,
94 include: [
95 buildAccountInclude(true, true),
96 buildVideoInclude(true)
97 ]
98 },
99
100 {
101 attributes: [ 'id' ],
102 model: () => VideoAbuseModel.unscoped(),
103 required: false,
104 include: [ buildVideoInclude(true) ]
105 },
106
107 {
108 attributes: [ 'id' ],
109 model: () => VideoBlacklistModel.unscoped(),
110 required: false,
111 include: [ buildVideoInclude(true) ]
112 },
113
114 {
115 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
116 model: () => VideoImportModel.unscoped(),
117 required: false,
118 include: [ buildVideoInclude(false) ]
119 },
120
121 {
122 attributes: [ 'id' ],
123 model: () => ActorFollowModel.unscoped(),
124 required: false,
125 include: [
126 {
127 attributes: [ 'preferredUsername' ],
128 model: () => ActorModel.unscoped(),
129 required: true,
130 as: 'ActorFollower',
131 include: [
132 {
133 attributes: [ 'id', 'name' ],
134 model: () => AccountModel.unscoped(),
135 required: true
136 },
137 {
138 attributes: [ 'filename' ],
139 model: () => AvatarModel.unscoped(),
140 required: false
141 },
142 {
143 attributes: [ 'host' ],
144 model: () => ServerModel.unscoped(),
145 required: false
146 }
147 ]
148 },
149 {
150 attributes: [ 'preferredUsername' ],
151 model: () => ActorModel.unscoped(),
152 required: true,
153 as: 'ActorFollowing',
154 include: [
155 buildChannelInclude(false),
156 buildAccountInclude(false)
157 ]
158 }
159 ]
160 },
161
162 buildAccountInclude(false, true)
163 ]
164 }
165})
166@Table({
167 tableName: 'userNotification',
168 indexes: [
169 {
170 fields: [ 'userId' ]
171 },
172 {
173 fields: [ 'videoId' ],
174 where: {
175 videoId: {
176 [Op.ne]: null
177 }
178 }
179 },
180 {
181 fields: [ 'commentId' ],
182 where: {
183 commentId: {
184 [Op.ne]: null
185 }
186 }
187 },
188 {
189 fields: [ 'videoAbuseId' ],
190 where: {
191 videoAbuseId: {
192 [Op.ne]: null
193 }
194 }
195 },
196 {
197 fields: [ 'videoBlacklistId' ],
198 where: {
199 videoBlacklistId: {
200 [Op.ne]: null
201 }
202 }
203 },
204 {
205 fields: [ 'videoImportId' ],
206 where: {
207 videoImportId: {
208 [Op.ne]: null
209 }
210 }
211 },
212 {
213 fields: [ 'accountId' ],
214 where: {
215 accountId: {
216 [Op.ne]: null
217 }
218 }
219 },
220 {
221 fields: [ 'actorFollowId' ],
222 where: {
223 actorFollowId: {
224 [Op.ne]: null
225 }
226 }
227 }
228 ]
229})
230export class UserNotificationModel extends Model<UserNotificationModel> {
231
232 @AllowNull(false)
233 @Default(null)
234 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
235 @Column
236 type: UserNotificationType
237
238 @AllowNull(false)
239 @Default(false)
240 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
241 @Column
242 read: boolean
243
244 @CreatedAt
245 createdAt: Date
246
247 @UpdatedAt
248 updatedAt: Date
249
250 @ForeignKey(() => UserModel)
251 @Column
252 userId: number
253
254 @BelongsTo(() => UserModel, {
255 foreignKey: {
256 allowNull: false
257 },
258 onDelete: 'cascade'
259 })
260 User: UserModel
261
262 @ForeignKey(() => VideoModel)
263 @Column
264 videoId: number
265
266 @BelongsTo(() => VideoModel, {
267 foreignKey: {
268 allowNull: true
269 },
270 onDelete: 'cascade'
271 })
272 Video: VideoModel
273
274 @ForeignKey(() => VideoCommentModel)
275 @Column
276 commentId: number
277
278 @BelongsTo(() => VideoCommentModel, {
279 foreignKey: {
280 allowNull: true
281 },
282 onDelete: 'cascade'
283 })
284 Comment: VideoCommentModel
285
286 @ForeignKey(() => VideoAbuseModel)
287 @Column
288 videoAbuseId: number
289
290 @BelongsTo(() => VideoAbuseModel, {
291 foreignKey: {
292 allowNull: true
293 },
294 onDelete: 'cascade'
295 })
296 VideoAbuse: VideoAbuseModel
297
298 @ForeignKey(() => VideoBlacklistModel)
299 @Column
300 videoBlacklistId: number
301
302 @BelongsTo(() => VideoBlacklistModel, {
303 foreignKey: {
304 allowNull: true
305 },
306 onDelete: 'cascade'
307 })
308 VideoBlacklist: VideoBlacklistModel
309
310 @ForeignKey(() => VideoImportModel)
311 @Column
312 videoImportId: number
313
314 @BelongsTo(() => VideoImportModel, {
315 foreignKey: {
316 allowNull: true
317 },
318 onDelete: 'cascade'
319 })
320 VideoImport: VideoImportModel
321
322 @ForeignKey(() => AccountModel)
323 @Column
324 accountId: number
325
326 @BelongsTo(() => AccountModel, {
327 foreignKey: {
328 allowNull: true
329 },
330 onDelete: 'cascade'
331 })
332 Account: AccountModel
333
334 @ForeignKey(() => ActorFollowModel)
335 @Column
336 actorFollowId: number
337
338 @BelongsTo(() => ActorFollowModel, {
339 foreignKey: {
340 allowNull: true
341 },
342 onDelete: 'cascade'
343 })
344 ActorFollow: ActorFollowModel
345
346 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
347 const query: IFindOptions<UserNotificationModel> = {
348 offset: start,
349 limit: count,
350 order: getSort(sort),
351 where: {
352 userId
353 }
354 }
355
356 if (unread !== undefined) query.where['read'] = !unread
357
358 return UserNotificationModel.scope(ScopeNames.WITH_ALL)
359 .findAndCountAll(query)
360 .then(({ rows, count }) => {
361 return {
362 data: rows,
363 total: count
364 }
365 })
366 }
367
368 static markAsRead (userId: number, notificationIds: number[]) {
369 const query = {
370 where: {
371 userId,
372 id: {
373 [Op.any]: notificationIds
374 }
375 }
376 }
377
378 return UserNotificationModel.update({ read: true }, query)
379 }
380
381 static markAllAsRead (userId: number) {
382 const query = { where: { userId } }
383
384 return UserNotificationModel.update({ read: true }, query)
385 }
386
387 toFormattedJSON (): UserNotification {
388 const video = this.Video
389 ? Object.assign(this.formatVideo(this.Video),{ channel: this.formatActor(this.Video.VideoChannel) })
390 : undefined
391
392 const videoImport = this.VideoImport ? {
393 id: this.VideoImport.id,
394 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
395 torrentName: this.VideoImport.torrentName,
396 magnetUri: this.VideoImport.magnetUri,
397 targetUrl: this.VideoImport.targetUrl
398 } : undefined
399
400 const comment = this.Comment ? {
401 id: this.Comment.id,
402 threadId: this.Comment.getThreadId(),
403 account: this.formatActor(this.Comment.Account),
404 video: this.formatVideo(this.Comment.Video)
405 } : undefined
406
407 const videoAbuse = this.VideoAbuse ? {
408 id: this.VideoAbuse.id,
409 video: this.formatVideo(this.VideoAbuse.Video)
410 } : undefined
411
412 const videoBlacklist = this.VideoBlacklist ? {
413 id: this.VideoBlacklist.id,
414 video: this.formatVideo(this.VideoBlacklist.Video)
415 } : undefined
416
417 const account = this.Account ? this.formatActor(this.Account) : undefined
418
419 const actorFollow = this.ActorFollow ? {
420 id: this.ActorFollow.id,
421 follower: {
422 id: this.ActorFollow.ActorFollower.Account.id,
423 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
424 name: this.ActorFollow.ActorFollower.preferredUsername,
425 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getWebserverPath() } : undefined,
426 host: this.ActorFollow.ActorFollower.getHost()
427 },
428 following: {
429 type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
430 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
431 name: this.ActorFollow.ActorFollowing.preferredUsername
432 }
433 } : undefined
434
435 return {
436 id: this.id,
437 type: this.type,
438 read: this.read,
439 video,
440 videoImport,
441 comment,
442 videoAbuse,
443 videoBlacklist,
444 account,
445 actorFollow,
446 createdAt: this.createdAt.toISOString(),
447 updatedAt: this.updatedAt.toISOString()
448 }
449 }
450
451 private formatVideo (video: VideoModel) {
452 return {
453 id: video.id,
454 uuid: video.uuid,
455 name: video.name
456 }
457 }
458
459 private formatActor (accountOrChannel: AccountModel | VideoChannelModel) {
460 const avatar = accountOrChannel.Actor.Avatar
461 ? { path: accountOrChannel.Actor.Avatar.getWebserverPath() }
462 : undefined
463
464 return {
465 id: accountOrChannel.id,
466 displayName: accountOrChannel.getDisplayName(),
467 name: accountOrChannel.Actor.preferredUsername,
468 host: accountOrChannel.Actor.getHost(),
469 avatar
470 }
471 }
472}
diff --git a/server/models/account/user-video-history.ts b/server/models/account/user-video-history.ts
index 0476cad9d..15cb399c9 100644
--- a/server/models/account/user-video-history.ts
+++ b/server/models/account/user-video-history.ts
@@ -1,6 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Min, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoModel } from '../video/video' 2import { VideoModel } from '../video/video'
3import { UserModel } from './user' 3import { UserModel } from './user'
4import { Transaction, Op, DestroyOptions } from 'sequelize'
4 5
5@Table({ 6@Table({
6 tableName: 'userVideoHistory', 7 tableName: 'userVideoHistory',
@@ -52,4 +53,34 @@ export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> {
52 onDelete: 'CASCADE' 53 onDelete: 'CASCADE'
53 }) 54 })
54 User: UserModel 55 User: UserModel
56
57 static listForApi (user: UserModel, start: number, count: number) {
58 return VideoModel.listForApi({
59 start,
60 count,
61 sort: '-UserVideoHistories.updatedAt',
62 nsfw: null, // All
63 includeLocalVideos: true,
64 withFiles: false,
65 user,
66 historyOfUser: user
67 })
68 }
69
70 static removeHistoryBefore (user: UserModel, beforeDate: string, t: Transaction) {
71 const query: DestroyOptions = {
72 where: {
73 userId: user.id
74 },
75 transaction: t
76 }
77
78 if (beforeDate) {
79 query.where.updatedAt = {
80 [Op.lt]: beforeDate
81 }
82 }
83
84 return UserVideoHistoryModel.destroy(query)
85 }
55} 86}
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index e56b0bf40..017a96657 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -1,6 +1,6 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { 2import {
3 AfterDelete, 3 AfterDestroy,
4 AfterUpdate, 4 AfterUpdate,
5 AllowNull, 5 AllowNull,
6 BeforeCreate, 6 BeforeCreate,
@@ -31,7 +31,9 @@ import {
31 isUserRoleValid, 31 isUserRoleValid,
32 isUserUsernameValid, 32 isUserUsernameValid,
33 isUserVideoQuotaDailyValid, 33 isUserVideoQuotaDailyValid,
34 isUserVideoQuotaValid 34 isUserVideoQuotaValid,
35 isUserVideosHistoryEnabledValid,
36 isUserWebTorrentEnabledValid
35} from '../../helpers/custom-validators/users' 37} from '../../helpers/custom-validators/users'
36import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' 38import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
37import { OAuthTokenModel } from '../oauth/oauth-token' 39import { OAuthTokenModel } from '../oauth/oauth-token'
@@ -42,6 +44,11 @@ import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
42import { values } from 'lodash' 44import { values } from 'lodash'
43import { NSFW_POLICY_TYPES } from '../../initializers' 45import { NSFW_POLICY_TYPES } from '../../initializers'
44import { clearCacheByUserId } from '../../lib/oauth-model' 46import { clearCacheByUserId } from '../../lib/oauth-model'
47import { UserNotificationSettingModel } from './user-notification-setting'
48import { VideoModel } from '../video/video'
49import { ActorModel } from '../activitypub/actor'
50import { ActorFollowModel } from '../activitypub/actor-follow'
51import { VideoImportModel } from '../video/video-import'
45 52
46enum ScopeNames { 53enum ScopeNames {
47 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL' 54 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
@@ -52,6 +59,10 @@ enum ScopeNames {
52 { 59 {
53 model: () => AccountModel, 60 model: () => AccountModel,
54 required: true 61 required: true
62 },
63 {
64 model: () => UserNotificationSettingModel,
65 required: true
55 } 66 }
56 ] 67 ]
57}) 68})
@@ -62,6 +73,10 @@ enum ScopeNames {
62 model: () => AccountModel, 73 model: () => AccountModel,
63 required: true, 74 required: true,
64 include: [ () => VideoChannelModel ] 75 include: [ () => VideoChannelModel ]
76 },
77 {
78 model: () => UserNotificationSettingModel,
79 required: true
65 } 80 }
66 ] 81 ]
67 } 82 }
@@ -109,6 +124,18 @@ export class UserModel extends Model<UserModel> {
109 124
110 @AllowNull(false) 125 @AllowNull(false)
111 @Default(true) 126 @Default(true)
127 @Is('UserWebTorrentEnabled', value => throwIfNotValid(value, isUserWebTorrentEnabledValid, 'WebTorrent enabled'))
128 @Column
129 webTorrentEnabled: boolean
130
131 @AllowNull(false)
132 @Default(true)
133 @Is('UserVideosHistoryEnabled', value => throwIfNotValid(value, isUserVideosHistoryEnabledValid, 'Videos history enabled'))
134 @Column
135 videosHistoryEnabled: boolean
136
137 @AllowNull(false)
138 @Default(true)
112 @Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean')) 139 @Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean'))
113 @Column 140 @Column
114 autoPlayVideo: boolean 141 autoPlayVideo: boolean
@@ -153,6 +180,19 @@ export class UserModel extends Model<UserModel> {
153 }) 180 })
154 Account: AccountModel 181 Account: AccountModel
155 182
183 @HasOne(() => UserNotificationSettingModel, {
184 foreignKey: 'userId',
185 onDelete: 'cascade',
186 hooks: true
187 })
188 NotificationSetting: UserNotificationSettingModel
189
190 @HasMany(() => VideoImportModel, {
191 foreignKey: 'userId',
192 onDelete: 'cascade'
193 })
194 VideoImports: VideoImportModel[]
195
156 @HasMany(() => OAuthTokenModel, { 196 @HasMany(() => OAuthTokenModel, {
157 foreignKey: 'userId', 197 foreignKey: 'userId',
158 onDelete: 'cascade' 198 onDelete: 'cascade'
@@ -172,7 +212,7 @@ export class UserModel extends Model<UserModel> {
172 } 212 }
173 213
174 @AfterUpdate 214 @AfterUpdate
175 @AfterDelete 215 @AfterDestroy
176 static removeTokenCache (instance: UserModel) { 216 static removeTokenCache (instance: UserModel) {
177 return clearCacheByUserId(instance.id) 217 return clearCacheByUserId(instance.id)
178 } 218 }
@@ -181,7 +221,25 @@ export class UserModel extends Model<UserModel> {
181 return this.count() 221 return this.count()
182 } 222 }
183 223
184 static listForApi (start: number, count: number, sort: string) { 224 static listForApi (start: number, count: number, sort: string, search?: string) {
225 let where = undefined
226 if (search) {
227 where = {
228 [Sequelize.Op.or]: [
229 {
230 email: {
231 [Sequelize.Op.iLike]: '%' + search + '%'
232 }
233 },
234 {
235 username: {
236 [ Sequelize.Op.iLike ]: '%' + search + '%'
237 }
238 }
239 ]
240 }
241 }
242
185 const query = { 243 const query = {
186 attributes: { 244 attributes: {
187 include: [ 245 include: [
@@ -204,7 +262,8 @@ export class UserModel extends Model<UserModel> {
204 }, 262 },
205 offset: start, 263 offset: start,
206 limit: count, 264 limit: count,
207 order: getSort(sort) 265 order: getSort(sort),
266 where
208 } 267 }
209 268
210 return UserModel.findAndCountAll(query) 269 return UserModel.findAndCountAll(query)
@@ -216,13 +275,12 @@ export class UserModel extends Model<UserModel> {
216 }) 275 })
217 } 276 }
218 277
219 static listEmailsWithRight (right: UserRight) { 278 static listWithRight (right: UserRight) {
220 const roles = Object.keys(USER_ROLE_LABELS) 279 const roles = Object.keys(USER_ROLE_LABELS)
221 .map(k => parseInt(k, 10) as UserRole) 280 .map(k => parseInt(k, 10) as UserRole)
222 .filter(role => hasUserRight(role, right)) 281 .filter(role => hasUserRight(role, right))
223 282
224 const query = { 283 const query = {
225 attribute: [ 'email' ],
226 where: { 284 where: {
227 role: { 285 role: {
228 [Sequelize.Op.in]: roles 286 [Sequelize.Op.in]: roles
@@ -230,9 +288,56 @@ export class UserModel extends Model<UserModel> {
230 } 288 }
231 } 289 }
232 290
233 return UserModel.unscoped() 291 return UserModel.findAll(query)
234 .findAll(query) 292 }
235 .then(u => u.map(u => u.email)) 293
294 static listUserSubscribersOf (actorId: number) {
295 const query = {
296 include: [
297 {
298 model: UserNotificationSettingModel.unscoped(),
299 required: true
300 },
301 {
302 attributes: [ 'userId' ],
303 model: AccountModel.unscoped(),
304 required: true,
305 include: [
306 {
307 attributes: [ ],
308 model: ActorModel.unscoped(),
309 required: true,
310 where: {
311 serverId: null
312 },
313 include: [
314 {
315 attributes: [ ],
316 as: 'ActorFollowings',
317 model: ActorFollowModel.unscoped(),
318 required: true,
319 where: {
320 targetActorId: actorId
321 }
322 }
323 ]
324 }
325 ]
326 }
327 ]
328 }
329
330 return UserModel.unscoped().findAll(query)
331 }
332
333 static listByUsernames (usernames: string[]) {
334 const query = {
335 where: {
336 username: usernames
337 }
338 }
339
340 return UserModel.findAll(query)
236 } 341 }
237 342
238 static loadById (id: number) { 343 static loadById (id: number) {
@@ -281,6 +386,95 @@ export class UserModel extends Model<UserModel> {
281 return UserModel.findOne(query) 386 return UserModel.findOne(query)
282 } 387 }
283 388
389 static loadByVideoId (videoId: number) {
390 const query = {
391 include: [
392 {
393 required: true,
394 attributes: [ 'id' ],
395 model: AccountModel.unscoped(),
396 include: [
397 {
398 required: true,
399 attributes: [ 'id' ],
400 model: VideoChannelModel.unscoped(),
401 include: [
402 {
403 required: true,
404 attributes: [ 'id' ],
405 model: VideoModel.unscoped(),
406 where: {
407 id: videoId
408 }
409 }
410 ]
411 }
412 ]
413 }
414 ]
415 }
416
417 return UserModel.findOne(query)
418 }
419
420 static loadByVideoImportId (videoImportId: number) {
421 const query = {
422 include: [
423 {
424 required: true,
425 attributes: [ 'id' ],
426 model: VideoImportModel.unscoped(),
427 where: {
428 id: videoImportId
429 }
430 }
431 ]
432 }
433
434 return UserModel.findOne(query)
435 }
436
437 static loadByChannelActorId (videoChannelActorId: number) {
438 const query = {
439 include: [
440 {
441 required: true,
442 attributes: [ 'id' ],
443 model: AccountModel.unscoped(),
444 include: [
445 {
446 required: true,
447 attributes: [ 'id' ],
448 model: VideoChannelModel.unscoped(),
449 where: {
450 actorId: videoChannelActorId
451 }
452 }
453 ]
454 }
455 ]
456 }
457
458 return UserModel.findOne(query)
459 }
460
461 static loadByAccountActorId (accountActorId: number) {
462 const query = {
463 include: [
464 {
465 required: true,
466 attributes: [ 'id' ],
467 model: AccountModel.unscoped(),
468 where: {
469 actorId: accountActorId
470 }
471 }
472 ]
473 }
474
475 return UserModel.findOne(query)
476 }
477
284 static getOriginalVideoFileTotalFromUser (user: UserModel) { 478 static getOriginalVideoFileTotalFromUser (user: UserModel) {
285 // Don't use sequelize because we need to use a sub query 479 // Don't use sequelize because we need to use a sub query
286 const query = UserModel.generateUserQuotaBaseSQL() 480 const query = UserModel.generateUserQuotaBaseSQL()
@@ -336,6 +530,8 @@ export class UserModel extends Model<UserModel> {
336 email: this.email, 530 email: this.email,
337 emailVerified: this.emailVerified, 531 emailVerified: this.emailVerified,
338 nsfwPolicy: this.nsfwPolicy, 532 nsfwPolicy: this.nsfwPolicy,
533 webTorrentEnabled: this.webTorrentEnabled,
534 videosHistoryEnabled: this.videosHistoryEnabled,
339 autoPlayVideo: this.autoPlayVideo, 535 autoPlayVideo: this.autoPlayVideo,
340 role: this.role, 536 role: this.role,
341 roleLabel: USER_ROLE_LABELS[ this.role ], 537 roleLabel: USER_ROLE_LABELS[ this.role ],
@@ -345,6 +541,7 @@ export class UserModel extends Model<UserModel> {
345 blocked: this.blocked, 541 blocked: this.blocked,
346 blockedReason: this.blockedReason, 542 blockedReason: this.blockedReason,
347 account: this.Account.toFormattedJSON(), 543 account: this.Account.toFormattedJSON(),
544 notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined,
348 videoChannels: [], 545 videoChannels: [],
349 videoQuotaUsed: videoQuotaUsed !== undefined 546 videoQuotaUsed: videoQuotaUsed !== undefined
350 ? parseInt(videoQuotaUsed, 10) 547 ? parseInt(videoQuotaUsed, 10)
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts
index 27bb43dae..796e07a42 100644
--- a/server/models/activitypub/actor-follow.ts
+++ b/server/models/activitypub/actor-follow.ts
@@ -127,22 +127,6 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
127 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved) 127 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
128 } 128 }
129 129
130 static updateActorFollowsScore (goodInboxes: string[], badInboxes: string[], t: Sequelize.Transaction | undefined) {
131 if (goodInboxes.length === 0 && badInboxes.length === 0) return
132
133 logger.info('Updating %d good actor follows and %d bad actor follows scores.', goodInboxes.length, badInboxes.length)
134
135 if (goodInboxes.length !== 0) {
136 ActorFollowModel.incrementScores(goodInboxes, ACTOR_FOLLOW_SCORE.BONUS, t)
137 .catch(err => logger.error('Cannot increment scores of good actor follows.', { err }))
138 }
139
140 if (badInboxes.length !== 0) {
141 ActorFollowModel.incrementScores(badInboxes, ACTOR_FOLLOW_SCORE.PENALTY, t)
142 .catch(err => logger.error('Cannot decrement scores of bad actor follows.', { err }))
143 }
144 }
145
146 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Sequelize.Transaction) { 130 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Sequelize.Transaction) {
147 const query = { 131 const query = {
148 where: { 132 where: {
@@ -280,7 +264,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
280 return ActorFollowModel.findAll(query) 264 return ActorFollowModel.findAll(query)
281 } 265 }
282 266
283 static listFollowingForApi (id: number, start: number, count: number, sort: string) { 267 static listFollowingForApi (id: number, start: number, count: number, sort: string, search?: string) {
284 const query = { 268 const query = {
285 distinct: true, 269 distinct: true,
286 offset: start, 270 offset: start,
@@ -299,7 +283,17 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
299 model: ActorModel, 283 model: ActorModel,
300 as: 'ActorFollowing', 284 as: 'ActorFollowing',
301 required: true, 285 required: true,
302 include: [ ServerModel ] 286 include: [
287 {
288 model: ServerModel,
289 required: true,
290 where: search ? {
291 host: {
292 [Sequelize.Op.iLike]: '%' + search + '%'
293 }
294 } : undefined
295 }
296 ]
303 } 297 }
304 ] 298 ]
305 } 299 }
@@ -313,7 +307,50 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
313 }) 307 })
314 } 308 }
315 309
316 static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { 310 static listFollowersForApi (actorId: number, start: number, count: number, sort: string, search?: string) {
311 const query = {
312 distinct: true,
313 offset: start,
314 limit: count,
315 order: getSort(sort),
316 include: [
317 {
318 model: ActorModel,
319 required: true,
320 as: 'ActorFollower',
321 include: [
322 {
323 model: ServerModel,
324 required: true,
325 where: search ? {
326 host: {
327 [ Sequelize.Op.iLike ]: '%' + search + '%'
328 }
329 } : undefined
330 }
331 ]
332 },
333 {
334 model: ActorModel,
335 as: 'ActorFollowing',
336 required: true,
337 where: {
338 id: actorId
339 }
340 }
341 ]
342 }
343
344 return ActorFollowModel.findAndCountAll(query)
345 .then(({ rows, count }) => {
346 return {
347 data: rows,
348 total: count
349 }
350 })
351 }
352
353 static listSubscriptionsForApi (actorId: number, start: number, count: number, sort: string) {
317 const query = { 354 const query = {
318 attributes: [], 355 attributes: [],
319 distinct: true, 356 distinct: true,
@@ -321,7 +358,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
321 limit: count, 358 limit: count,
322 order: getSort(sort), 359 order: getSort(sort),
323 where: { 360 where: {
324 actorId: id 361 actorId: actorId
325 }, 362 },
326 include: [ 363 include: [
327 { 364 {
@@ -370,39 +407,6 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
370 }) 407 })
371 } 408 }
372 409
373 static listFollowersForApi (id: number, start: number, count: number, sort: string) {
374 const query = {
375 distinct: true,
376 offset: start,
377 limit: count,
378 order: getSort(sort),
379 include: [
380 {
381 model: ActorModel,
382 required: true,
383 as: 'ActorFollower',
384 include: [ ServerModel ]
385 },
386 {
387 model: ActorModel,
388 as: 'ActorFollowing',
389 required: true,
390 where: {
391 id
392 }
393 }
394 ]
395 }
396
397 return ActorFollowModel.findAndCountAll(query)
398 .then(({ rows, count }) => {
399 return {
400 data: rows,
401 total: count
402 }
403 })
404 }
405
406 static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { 410 static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
407 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) 411 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
408 } 412 }
@@ -444,6 +448,22 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
444 } 448 }
445 } 449 }
446 450
451 static updateFollowScore (inboxUrl: string, value: number, t?: Sequelize.Transaction) {
452 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
453 'WHERE id IN (' +
454 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
455 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
456 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
457 ')'
458
459 const options = {
460 type: Sequelize.QueryTypes.BULKUPDATE,
461 transaction: t
462 }
463
464 return ActorFollowModel.sequelize.query(query, options)
465 }
466
447 private static async createListAcceptedFollowForApiQuery ( 467 private static async createListAcceptedFollowForApiQuery (
448 type: 'followers' | 'following', 468 type: 'followers' | 'following',
449 actorIds: number[], 469 actorIds: number[],
@@ -489,33 +509,15 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
489 tasks.push(ActorFollowModel.sequelize.query(query, options)) 509 tasks.push(ActorFollowModel.sequelize.query(query, options))
490 } 510 }
491 511
492 const [ followers, [ { total } ] ] = await Promise.all(tasks) 512 const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
493 const urls: string[] = followers.map(f => f.url) 513 const urls: string[] = followers.map(f => f.url)
494 514
495 return { 515 return {
496 data: urls, 516 data: urls,
497 total: parseInt(total, 10) 517 total: dataTotal ? parseInt(dataTotal.total, 10) : 0
498 } 518 }
499 } 519 }
500 520
501 private static incrementScores (inboxUrls: string[], value: number, t: Sequelize.Transaction | undefined) {
502 const inboxUrlsString = inboxUrls.map(url => `'${url}'`).join(',')
503
504 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
505 'WHERE id IN (' +
506 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
507 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
508 'WHERE "actor"."inboxUrl" IN (' + inboxUrlsString + ') OR "actor"."sharedInboxUrl" IN (' + inboxUrlsString + ')' +
509 ')'
510
511 const options = t ? {
512 type: Sequelize.QueryTypes.BULKUPDATE,
513 transaction: t
514 } : undefined
515
516 return ActorFollowModel.sequelize.query(query, options)
517 }
518
519 private static listBadActorFollows () { 521 private static listBadActorFollows () {
520 const query = { 522 const query = {
521 where: { 523 where: {
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index 12b83916e..dda57a8ba 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -219,6 +219,7 @@ export class ActorModel extends Model<ActorModel> {
219 name: 'actorId', 219 name: 'actorId',
220 allowNull: false 220 allowNull: false
221 }, 221 },
222 as: 'ActorFollowings',
222 onDelete: 'cascade' 223 onDelete: 'cascade'
223 }) 224 })
224 ActorFollowing: ActorFollowModel[] 225 ActorFollowing: ActorFollowModel[]
diff --git a/server/models/avatar/avatar.ts b/server/models/avatar/avatar.ts
index 5d73e24fa..303aebcc2 100644
--- a/server/models/avatar/avatar.ts
+++ b/server/models/avatar/avatar.ts
@@ -23,7 +23,10 @@ export class AvatarModel extends Model<AvatarModel> {
23 @AfterDestroy 23 @AfterDestroy
24 static removeFilesAndSendDelete (instance: AvatarModel) { 24 static removeFilesAndSendDelete (instance: AvatarModel) {
25 logger.info('Removing avatar file %s.', instance.filename) 25 logger.info('Removing avatar file %s.', instance.filename)
26 return instance.removeAvatar() 26
27 // Don't block the transaction
28 instance.removeAvatar()
29 .catch(err => logger.error('Cannot remove avatar file %s.', instance.filename, err))
27 } 30 }
28 31
29 toFormattedJSON (): Avatar { 32 toFormattedJSON (): Avatar {
diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts
index ef9592c04..08d892da4 100644
--- a/server/models/oauth/oauth-token.ts
+++ b/server/models/oauth/oauth-token.ts
@@ -1,5 +1,5 @@
1import { 1import {
2 AfterDelete, 2 AfterDestroy,
3 AfterUpdate, 3 AfterUpdate,
4 AllowNull, 4 AllowNull,
5 BelongsTo, 5 BelongsTo,
@@ -47,7 +47,7 @@ enum ScopeNames {
47 required: true, 47 required: true,
48 include: [ 48 include: [
49 { 49 {
50 attributes: [ 'id' ], 50 attributes: [ 'id', 'url' ],
51 model: () => ActorModel.unscoped(), 51 model: () => ActorModel.unscoped(),
52 required: true 52 required: true
53 } 53 }
@@ -126,7 +126,7 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
126 OAuthClients: OAuthClientModel[] 126 OAuthClients: OAuthClientModel[]
127 127
128 @AfterUpdate 128 @AfterUpdate
129 @AfterDelete 129 @AfterDestroy
130 static removeTokenCache (token: OAuthTokenModel) { 130 static removeTokenCache (token: OAuthTokenModel) {
131 return clearCacheByToken(token.accessToken) 131 return clearCacheByToken(token.accessToken)
132 } 132 }
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 2ebe23ef1..b722bed14 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -15,7 +15,7 @@ import {
15import { ActorModel } from '../activitypub/actor' 15import { ActorModel } from '../activitypub/actor'
16import { getVideoSort, throwIfNotValid } from '../utils' 16import { getVideoSort, throwIfNotValid } from '../utils'
17import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc' 17import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
18import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers' 18import { CONFIG, CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers'
19import { VideoFileModel } from '../video/video-file' 19import { VideoFileModel } from '../video/video-file'
20import { getServerActor } from '../../helpers/utils' 20import { getServerActor } from '../../helpers/utils'
21import { VideoModel } from '../video/video' 21import { VideoModel } from '../video/video'
@@ -28,6 +28,7 @@ import { sample } from 'lodash'
28import { isTestInstance } from '../../helpers/core-utils' 28import { isTestInstance } from '../../helpers/core-utils'
29import * as Bluebird from 'bluebird' 29import * as Bluebird from 'bluebird'
30import * as Sequelize from 'sequelize' 30import * as Sequelize from 'sequelize'
31import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
31 32
32export enum ScopeNames { 33export enum ScopeNames {
33 WITH_VIDEO = 'WITH_VIDEO' 34 WITH_VIDEO = 'WITH_VIDEO'
@@ -38,7 +39,17 @@ export enum ScopeNames {
38 include: [ 39 include: [
39 { 40 {
40 model: () => VideoFileModel, 41 model: () => VideoFileModel,
41 required: true, 42 required: false,
43 include: [
44 {
45 model: () => VideoModel,
46 required: true
47 }
48 ]
49 },
50 {
51 model: () => VideoStreamingPlaylistModel,
52 required: false,
42 include: [ 53 include: [
43 { 54 {
44 model: () => VideoModel, 55 model: () => VideoModel,
@@ -97,12 +108,24 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
97 108
98 @BelongsTo(() => VideoFileModel, { 109 @BelongsTo(() => VideoFileModel, {
99 foreignKey: { 110 foreignKey: {
100 allowNull: false 111 allowNull: true
101 }, 112 },
102 onDelete: 'cascade' 113 onDelete: 'cascade'
103 }) 114 })
104 VideoFile: VideoFileModel 115 VideoFile: VideoFileModel
105 116
117 @ForeignKey(() => VideoStreamingPlaylistModel)
118 @Column
119 videoStreamingPlaylistId: number
120
121 @BelongsTo(() => VideoStreamingPlaylistModel, {
122 foreignKey: {
123 allowNull: true
124 },
125 onDelete: 'cascade'
126 })
127 VideoStreamingPlaylist: VideoStreamingPlaylistModel
128
106 @ForeignKey(() => ActorModel) 129 @ForeignKey(() => ActorModel)
107 @Column 130 @Column
108 actorId: number 131 actorId: number
@@ -117,16 +140,27 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
117 140
118 @BeforeDestroy 141 @BeforeDestroy
119 static async removeFile (instance: VideoRedundancyModel) { 142 static async removeFile (instance: VideoRedundancyModel) {
120 // Not us 143 if (!instance.isOwned()) return
121 if (!instance.strategy) return
122 144
123 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId) 145 if (instance.videoFileId) {
146 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
124 147
125 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` 148 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
126 logger.info('Removing duplicated video file %s.', logIdentifier) 149 logger.info('Removing duplicated video file %s.', logIdentifier)
127 150
128 videoFile.Video.removeFile(videoFile) 151 videoFile.Video.removeFile(videoFile, true)
129 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) 152 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
153 }
154
155 if (instance.videoStreamingPlaylistId) {
156 const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
157
158 const videoUUID = videoStreamingPlaylist.Video.uuid
159 logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
160
161 videoStreamingPlaylist.Video.removeStreamingPlaylist(true)
162 .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
163 }
130 164
131 return undefined 165 return undefined
132 } 166 }
@@ -144,6 +178,19 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
144 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) 178 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
145 } 179 }
146 180
181 static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number) {
182 const actor = await getServerActor()
183
184 const query = {
185 where: {
186 actorId: actor.id,
187 videoStreamingPlaylistId
188 }
189 }
190
191 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
192 }
193
147 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 194 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
148 const query = { 195 const query = {
149 where: { 196 where: {
@@ -192,7 +239,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
192 const ids = rows.map(r => r.id) 239 const ids = rows.map(r => r.id)
193 const id = sample(ids) 240 const id = sample(ids)
194 241
195 return VideoModel.loadWithFile(id, undefined, !isTestInstance()) 242 return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
196 } 243 }
197 244
198 static async findMostViewToDuplicate (randomizedFactor: number) { 245 static async findMostViewToDuplicate (randomizedFactor: number) {
@@ -293,6 +340,11 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
293 } 340 }
294 341
295 return VideoFileModel.sum('size', options as any) // FIXME: typings 342 return VideoFileModel.sum('size', options as any) // FIXME: typings
343 .then(v => {
344 if (!v || isNaN(v)) return 0
345
346 return v
347 })
296 } 348 }
297 349
298 static async listLocalExpired () { 350 static async listLocalExpired () {
@@ -329,40 +381,44 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
329 381
330 static async listLocalOfServer (serverId: number) { 382 static async listLocalOfServer (serverId: number) {
331 const actor = await getServerActor() 383 const actor = await getServerActor()
332 384 const buildVideoInclude = () => ({
333 const query = { 385 model: VideoModel,
334 where: { 386 required: true,
335 actorId: actor.id
336 },
337 include: [ 387 include: [
338 { 388 {
339 model: VideoFileModel, 389 attributes: [],
390 model: VideoChannelModel.unscoped(),
340 required: true, 391 required: true,
341 include: [ 392 include: [
342 { 393 {
343 model: VideoModel, 394 attributes: [],
395 model: ActorModel.unscoped(),
344 required: true, 396 required: true,
345 include: [ 397 where: {
346 { 398 serverId
347 attributes: [], 399 }
348 model: VideoChannelModel.unscoped(),
349 required: true,
350 include: [
351 {
352 attributes: [],
353 model: ActorModel.unscoped(),
354 required: true,
355 where: {
356 serverId
357 }
358 }
359 ]
360 }
361 ]
362 } 400 }
363 ] 401 ]
364 } 402 }
365 ] 403 ]
404 })
405
406 const query = {
407 where: {
408 actorId: actor.id
409 },
410 include: [
411 {
412 model: VideoFileModel,
413 required: false,
414 include: [ buildVideoInclude() ]
415 },
416 {
417 model: VideoStreamingPlaylistModel,
418 required: false,
419 include: [ buildVideoInclude() ]
420 }
421 ]
366 } 422 }
367 423
368 return VideoRedundancyModel.findAll(query) 424 return VideoRedundancyModel.findAll(query)
@@ -391,7 +447,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
391 ] 447 ]
392 } 448 }
393 449
394 return VideoRedundancyModel.find(query as any) // FIXME: typings 450 return VideoRedundancyModel.findOne(query as any) // FIXME: typings
395 .then((r: any) => ({ 451 .then((r: any) => ({
396 totalUsed: parseInt(r.totalUsed.toString(), 10), 452 totalUsed: parseInt(r.totalUsed.toString(), 10),
397 totalVideos: r.totalVideos, 453 totalVideos: r.totalVideos,
@@ -399,7 +455,32 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
399 })) 455 }))
400 } 456 }
401 457
458 getVideo () {
459 if (this.VideoFile) return this.VideoFile.Video
460
461 return this.VideoStreamingPlaylist.Video
462 }
463
464 isOwned () {
465 return !!this.strategy
466 }
467
402 toActivityPubObject (): CacheFileObject { 468 toActivityPubObject (): CacheFileObject {
469 if (this.VideoStreamingPlaylist) {
470 return {
471 id: this.url,
472 type: 'CacheFile' as 'CacheFile',
473 object: this.VideoStreamingPlaylist.Video.url,
474 expires: this.expiresOn.toISOString(),
475 url: {
476 type: 'Link',
477 mimeType: 'application/x-mpegURL',
478 mediaType: 'application/x-mpegURL',
479 href: this.fileUrl
480 }
481 }
482 }
483
403 return { 484 return {
404 id: this.url, 485 id: this.url,
405 type: 'CacheFile' as 'CacheFile', 486 type: 'CacheFile' as 'CacheFile',
@@ -407,7 +488,8 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
407 expires: this.expiresOn.toISOString(), 488 expires: this.expiresOn.toISOString(),
408 url: { 489 url: {
409 type: 'Link', 490 type: 'Link',
410 mimeType: VIDEO_EXT_MIMETYPE[ this.VideoFile.extname ] as any, 491 mimeType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any,
492 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any,
411 href: this.fileUrl, 493 href: this.fileUrl,
412 height: this.VideoFile.resolution, 494 height: this.VideoFile.resolution,
413 size: this.VideoFile.size, 495 size: this.VideoFile.size,
@@ -422,7 +504,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
422 504
423 const notIn = Sequelize.literal( 505 const notIn = Sequelize.literal(
424 '(' + 506 '(' +
425 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` + 507 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` +
426 ')' 508 ')'
427 ) 509 )
428 510
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
new file mode 100644
index 000000000..450f27152
--- /dev/null
+++ b/server/models/server/server-blocklist.ts
@@ -0,0 +1,121 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { AccountModel } from '../account/account'
3import { ServerModel } from './server'
4import { ServerBlock } from '../../../shared/models/blocklist'
5import { getSort } from '../utils'
6
7enum ScopeNames {
8 WITH_ACCOUNT = 'WITH_ACCOUNT',
9 WITH_SERVER = 'WITH_SERVER'
10}
11
12@Scopes({
13 [ScopeNames.WITH_ACCOUNT]: {
14 include: [
15 {
16 model: () => AccountModel,
17 required: true
18 }
19 ]
20 },
21 [ScopeNames.WITH_SERVER]: {
22 include: [
23 {
24 model: () => ServerModel,
25 required: true
26 }
27 ]
28 }
29})
30
31@Table({
32 tableName: 'serverBlocklist',
33 indexes: [
34 {
35 fields: [ 'accountId', 'targetServerId' ],
36 unique: true
37 },
38 {
39 fields: [ 'targetServerId' ]
40 }
41 ]
42})
43export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
44
45 @CreatedAt
46 createdAt: Date
47
48 @UpdatedAt
49 updatedAt: Date
50
51 @ForeignKey(() => AccountModel)
52 @Column
53 accountId: number
54
55 @BelongsTo(() => AccountModel, {
56 foreignKey: {
57 name: 'accountId',
58 allowNull: false
59 },
60 onDelete: 'CASCADE'
61 })
62 ByAccount: AccountModel
63
64 @ForeignKey(() => ServerModel)
65 @Column
66 targetServerId: number
67
68 @BelongsTo(() => ServerModel, {
69 foreignKey: {
70 name: 'targetServerId',
71 allowNull: false
72 },
73 onDelete: 'CASCADE'
74 })
75 BlockedServer: ServerModel
76
77 static loadByAccountAndHost (accountId: number, host: string) {
78 const query = {
79 where: {
80 accountId
81 },
82 include: [
83 {
84 model: ServerModel,
85 where: {
86 host
87 },
88 required: true
89 }
90 ]
91 }
92
93 return ServerBlocklistModel.findOne(query)
94 }
95
96 static listForApi (accountId: number, start: number, count: number, sort: string) {
97 const query = {
98 offset: start,
99 limit: count,
100 order: getSort(sort),
101 where: {
102 accountId
103 }
104 }
105
106 return ServerBlocklistModel
107 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_SERVER ])
108 .findAndCountAll(query)
109 .then(({ rows, count }) => {
110 return { total: count, data: rows }
111 })
112 }
113
114 toFormattedJSON (): ServerBlock {
115 return {
116 byAccount: this.ByAccount.toFormattedJSON(),
117 blockedServer: this.BlockedServer.toFormattedJSON(),
118 createdAt: this.createdAt
119 }
120 }
121}
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index ca3b24d51..300d70938 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -49,4 +49,10 @@ export class ServerModel extends Model<ServerModel> {
49 49
50 return ServerModel.findOne(query) 50 return ServerModel.findOne(query)
51 } 51 }
52
53 toFormattedJSON () {
54 return {
55 host: this.host
56 }
57 }
52} 58}
diff --git a/server/models/utils.ts b/server/models/utils.ts
index e0bf091ad..5b4093aec 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -29,7 +29,11 @@ function getVideoSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
29 ] 29 ]
30 } 30 }
31 31
32 return [ [ field, direction ], lastSort ] 32 const firstSort = typeof field === 'string' ?
33 field.split('.').concat([ direction ]) :
34 [ field, direction ]
35
36 return [ firstSort, lastSort ]
33} 37}
34 38
35function getSortOnModel (model: any, value: string, lastSort: string[] = [ 'id', 'ASC' ]) { 39function getSortOnModel (model: any, value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
@@ -64,9 +68,25 @@ function createSimilarityAttribute (col: string, value: string) {
64 ) 68 )
65} 69}
66 70
71function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number) {
72 const blockerIds = [ serverAccountId ]
73 if (userAccountId) blockerIds.push(userAccountId)
74
75 const blockerIdsString = blockerIds.join(', ')
76
77 const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
78 ' UNION ALL ' +
79 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
80 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
81 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
82
83 return query
84}
85
67// --------------------------------------------------------------------------- 86// ---------------------------------------------------------------------------
68 87
69export { 88export {
89 buildBlockedAccountSQL,
70 SortType, 90 SortType,
71 getSort, 91 getSort,
72 getVideoSort, 92 getVideoSort,
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index dbb88ca45..cc47644f2 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -1,17 +1,4 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 AfterCreate,
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' 2import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
16import { VideoAbuse } from '../../../shared/models/videos' 3import { VideoAbuse } from '../../../shared/models/videos'
17import { 4import {
@@ -19,7 +6,6 @@ import {
19 isVideoAbuseReasonValid, 6 isVideoAbuseReasonValid,
20 isVideoAbuseStateValid 7 isVideoAbuseStateValid
21} from '../../helpers/custom-validators/video-abuses' 8} from '../../helpers/custom-validators/video-abuses'
22import { Emailer } from '../../lib/emailer'
23import { AccountModel } from '../account/account' 9import { AccountModel } from '../account/account'
24import { getSort, throwIfNotValid } from '../utils' 10import { getSort, throwIfNotValid } from '../utils'
25import { VideoModel } from './video' 11import { VideoModel } from './video'
@@ -40,8 +26,9 @@ import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers'
40export class VideoAbuseModel extends Model<VideoAbuseModel> { 26export class VideoAbuseModel extends Model<VideoAbuseModel> {
41 27
42 @AllowNull(false) 28 @AllowNull(false)
29 @Default(null)
43 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason')) 30 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
44 @Column 31 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max))
45 reason: string 32 reason: string
46 33
47 @AllowNull(false) 34 @AllowNull(false)
@@ -86,11 +73,6 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
86 }) 73 })
87 Video: VideoModel 74 Video: VideoModel
88 75
89 @AfterCreate
90 static sendEmailNotification (instance: VideoAbuseModel) {
91 return Emailer.Instance.addVideoAbuseReportJob(instance.videoId)
92 }
93
94 static loadByIdAndVideoId (id: number, videoId: number) { 76 static loadByIdAndVideoId (id: number, videoId: number) {
95 const query = { 77 const query = {
96 where: { 78 where: {
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 67f7cd487..3b567e488 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,21 +1,7 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 AfterCreate,
3 AfterDestroy,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { getSortOnModel, SortType, throwIfNotValid } from '../utils' 2import { getSortOnModel, SortType, throwIfNotValid } from '../utils'
16import { VideoModel } from './video' 3import { VideoModel } from './video'
17import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' 4import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist'
18import { Emailer } from '../../lib/emailer'
19import { VideoBlacklist } from '../../../shared/models/videos' 5import { VideoBlacklist } from '../../../shared/models/videos'
20import { CONSTRAINTS_FIELDS } from '../../initializers' 6import { CONSTRAINTS_FIELDS } from '../../initializers'
21 7
@@ -35,6 +21,10 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
35 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max)) 21 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max))
36 reason: string 22 reason: string
37 23
24 @AllowNull(false)
25 @Column
26 unfederated: boolean
27
38 @CreatedAt 28 @CreatedAt
39 createdAt: Date 29 createdAt: Date
40 30
@@ -53,16 +43,6 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
53 }) 43 })
54 Video: VideoModel 44 Video: VideoModel
55 45
56 @AfterCreate
57 static sendBlacklistEmailNotification (instance: VideoBlacklistModel) {
58 return Emailer.Instance.addVideoBlacklistReportJob(instance.videoId, instance.reason)
59 }
60
61 @AfterDestroy
62 static sendUnblacklistEmailNotification (instance: VideoBlacklistModel) {
63 return Emailer.Instance.addVideoUnblacklistReportJob(instance.videoId)
64 }
65
66 static listForApi (start: number, count: number, sort: SortType) { 46 static listForApi (start: number, count: number, sort: SortType) {
67 const query = { 47 const query = {
68 offset: start, 48 offset: start,
@@ -103,6 +83,7 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
103 createdAt: this.createdAt, 83 createdAt: this.createdAt,
104 updatedAt: this.updatedAt, 84 updatedAt: this.updatedAt,
105 reason: this.reason, 85 reason: this.reason,
86 unfederated: this.unfederated,
106 87
107 video: { 88 video: {
108 id: video.id, 89 id: video.id,
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index f4586917e..5598d80f6 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -233,6 +233,27 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
233 }) 233 })
234 } 234 }
235 235
236 static listLocalsForSitemap (sort: string) {
237 const query = {
238 attributes: [ ],
239 offset: 0,
240 order: getSort(sort),
241 include: [
242 {
243 attributes: [ 'preferredUsername', 'serverId' ],
244 model: ActorModel.unscoped(),
245 where: {
246 serverId: null
247 }
248 }
249 ]
250 }
251
252 return VideoChannelModel
253 .unscoped()
254 .findAll(query)
255 }
256
236 static searchForApi (options: { 257 static searchForApi (options: {
237 actorId: number 258 actorId: number
238 search: string 259 search: string
@@ -449,4 +470,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
449 getDisplayName () { 470 getDisplayName () {
450 return this.name 471 return this.name
451 } 472 }
473
474 isOutdated () {
475 return this.Actor.isOutdated()
476 }
452} 477}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index f84c1880c..cf6278da7 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,21 +1,37 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { 2import {
3 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table, 3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 IFindOptions,
11 Is,
12 Model,
13 Scopes,
14 Table,
4 UpdatedAt 15 UpdatedAt
5} from 'sequelize-typescript' 16} from 'sequelize-typescript'
6import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects' 17import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
7import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 18import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
8import { VideoComment } from '../../../shared/models/videos/video-comment.model' 19import { VideoComment } from '../../../shared/models/videos/video-comment.model'
9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10import { CONSTRAINTS_FIELDS } from '../../initializers' 21import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
11import { sendDeleteVideoComment } from '../../lib/activitypub/send' 22import { sendDeleteVideoComment } from '../../lib/activitypub/send'
12import { AccountModel } from '../account/account' 23import { AccountModel } from '../account/account'
13import { ActorModel } from '../activitypub/actor' 24import { ActorModel } from '../activitypub/actor'
14import { AvatarModel } from '../avatar/avatar' 25import { AvatarModel } from '../avatar/avatar'
15import { ServerModel } from '../server/server' 26import { ServerModel } from '../server/server'
16import { getSort, throwIfNotValid } from '../utils' 27import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
17import { VideoModel } from './video' 28import { VideoModel } from './video'
18import { VideoChannelModel } from './video-channel' 29import { VideoChannelModel } from './video-channel'
30import { getServerActor } from '../../helpers/utils'
31import { UserModel } from '../account/user'
32import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
33import { regexpCapture } from '../../helpers/regexp'
34import { uniq } from 'lodash'
19 35
20enum ScopeNames { 36enum ScopeNames {
21 WITH_ACCOUNT = 'WITH_ACCOUNT', 37 WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -25,18 +41,29 @@ enum ScopeNames {
25} 41}
26 42
27@Scopes({ 43@Scopes({
28 [ScopeNames.ATTRIBUTES_FOR_API]: { 44 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
29 attributes: { 45 return {
30 include: [ 46 attributes: {
31 [ 47 include: [
32 Sequelize.literal( 48 [
33 '(SELECT COUNT("replies"."id") ' + 49 Sequelize.literal(
34 'FROM "videoComment" AS "replies" ' + 50 '(' +
35 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")' 51 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
36 ), 52 'SELECT COUNT("replies"."id") - (' +
37 'totalReplies' 53 'SELECT COUNT("replies"."id") ' +
54 'FROM "videoComment" AS "replies" ' +
55 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
56 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
57 ')' +
58 'FROM "videoComment" AS "replies" ' +
59 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
60 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
61 ')'
62 ),
63 'totalReplies'
64 ]
38 ] 65 ]
39 ] 66 }
40 } 67 }
41 }, 68 },
42 [ScopeNames.WITH_ACCOUNT]: { 69 [ScopeNames.WITH_ACCOUNT]: {
@@ -267,26 +294,47 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
267 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query) 294 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
268 } 295 }
269 296
270 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) { 297 static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
298 const serverActor = await getServerActor()
299 const serverAccountId = serverActor.Account.id
300 const userAccountId = user ? user.Account.id : undefined
301
271 const query = { 302 const query = {
272 offset: start, 303 offset: start,
273 limit: count, 304 limit: count,
274 order: getSort(sort), 305 order: getSort(sort),
275 where: { 306 where: {
276 videoId, 307 videoId,
277 inReplyToCommentId: null 308 inReplyToCommentId: null,
309 accountId: {
310 [Sequelize.Op.notIn]: Sequelize.literal(
311 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
312 )
313 }
278 } 314 }
279 } 315 }
280 316
317 // FIXME: typings
318 const scopes: any[] = [
319 ScopeNames.WITH_ACCOUNT,
320 {
321 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
322 }
323 ]
324
281 return VideoCommentModel 325 return VideoCommentModel
282 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ]) 326 .scope(scopes)
283 .findAndCountAll(query) 327 .findAndCountAll(query)
284 .then(({ rows, count }) => { 328 .then(({ rows, count }) => {
285 return { total: count, data: rows } 329 return { total: count, data: rows }
286 }) 330 })
287 } 331 }
288 332
289 static listThreadCommentsForApi (videoId: number, threadId: number) { 333 static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
334 const serverActor = await getServerActor()
335 const serverAccountId = serverActor.Account.id
336 const userAccountId = user ? user.Account.id : undefined
337
290 const query = { 338 const query = {
291 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ], 339 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
292 where: { 340 where: {
@@ -294,12 +342,24 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
294 [ Sequelize.Op.or ]: [ 342 [ Sequelize.Op.or ]: [
295 { id: threadId }, 343 { id: threadId },
296 { originCommentId: threadId } 344 { originCommentId: threadId }
297 ] 345 ],
346 accountId: {
347 [Sequelize.Op.notIn]: Sequelize.literal(
348 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
349 )
350 }
298 } 351 }
299 } 352 }
300 353
354 const scopes: any[] = [
355 ScopeNames.WITH_ACCOUNT,
356 {
357 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
358 }
359 ]
360
301 return VideoCommentModel 361 return VideoCommentModel
302 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ]) 362 .scope(scopes)
303 .findAndCountAll(query) 363 .findAndCountAll(query)
304 .then(({ rows, count }) => { 364 .then(({ rows, count }) => {
305 return { total: count, data: rows } 365 return { total: count, data: rows }
@@ -313,9 +373,11 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
313 id: { 373 id: {
314 [ Sequelize.Op.in ]: Sequelize.literal('(' + 374 [ Sequelize.Op.in ]: Sequelize.literal('(' +
315 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + 375 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
316 'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' + 376 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
317 'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' + 377 'UNION ' +
318 'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' + 378 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
379 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
380 ') ' +
319 'SELECT id FROM children' + 381 'SELECT id FROM children' +
320 ')'), 382 ')'),
321 [ Sequelize.Op.ne ]: comment.id 383 [ Sequelize.Op.ne ]: comment.id
@@ -391,6 +453,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
391 } 453 }
392 } 454 }
393 455
456 getCommentStaticPath () {
457 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
458 }
459
394 getThreadId (): number { 460 getThreadId (): number {
395 return this.originCommentId || this.id 461 return this.originCommentId || this.id
396 } 462 }
@@ -399,6 +465,34 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
399 return this.Account.isOwned() 465 return this.Account.isOwned()
400 } 466 }
401 467
468 extractMentions () {
469 if (!this.text) return []
470
471 const localMention = `@(${actorNameAlphabet}+)`
472 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
473
474 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
475 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
476 const firstMentionRegex = new RegExp('^(?:(?:' + remoteMention + ')|(?:' + localMention + ')) ', 'g')
477 const endMentionRegex = new RegExp(' (?:(?:' + remoteMention + ')|(?:' + localMention + '))$', 'g')
478
479 return uniq(
480 [].concat(
481 regexpCapture(this.text, remoteMentionsRegex)
482 .map(([ , username ]) => username),
483
484 regexpCapture(this.text, localMentionsRegex)
485 .map(([ , username ]) => username),
486
487 regexpCapture(this.text, firstMentionRegex)
488 .map(([ , username1, username2 ]) => username1 || username2),
489
490 regexpCapture(this.text, endMentionRegex)
491 .map(([ , username1, username2 ]) => username1 || username2)
492 )
493 )
494 }
495
402 toFormattedJSON () { 496 toFormattedJSON () {
403 return { 497 return {
404 id: this.id, 498 id: this.id,
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index adebdf0c7..7d1e371b9 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -1,4 +1,3 @@
1import { values } from 'lodash'
2import { 1import {
3 AllowNull, 2 AllowNull,
4 BelongsTo, 3 BelongsTo,
@@ -14,12 +13,12 @@ import {
14 UpdatedAt 13 UpdatedAt
15} from 'sequelize-typescript' 14} from 'sequelize-typescript'
16import { 15import {
16 isVideoFileExtnameValid,
17 isVideoFileInfoHashValid, 17 isVideoFileInfoHashValid,
18 isVideoFileResolutionValid, 18 isVideoFileResolutionValid,
19 isVideoFileSizeValid, 19 isVideoFileSizeValid,
20 isVideoFPSResolutionValid 20 isVideoFPSResolutionValid
21} from '../../helpers/custom-validators/videos' 21} from '../../helpers/custom-validators/videos'
22import { CONSTRAINTS_FIELDS } from '../../initializers'
23import { throwIfNotValid } from '../utils' 22import { throwIfNotValid } from '../utils'
24import { VideoModel } from './video' 23import { VideoModel } from './video'
25import * as Sequelize from 'sequelize' 24import * as Sequelize from 'sequelize'
@@ -58,11 +57,12 @@ export class VideoFileModel extends Model<VideoFileModel> {
58 size: number 57 size: number
59 58
60 @AllowNull(false) 59 @AllowNull(false)
61 @Column(DataType.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME))) 60 @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
61 @Column
62 extname: string 62 extname: string
63 63
64 @AllowNull(false) 64 @AllowNull(false)
65 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) 65 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
66 @Column 66 @Column
67 infoHash: string 67 infoHash: string
68 68
@@ -86,14 +86,14 @@ export class VideoFileModel extends Model<VideoFileModel> {
86 86
87 @HasMany(() => VideoRedundancyModel, { 87 @HasMany(() => VideoRedundancyModel, {
88 foreignKey: { 88 foreignKey: {
89 allowNull: false 89 allowNull: true
90 }, 90 },
91 onDelete: 'CASCADE', 91 onDelete: 'CASCADE',
92 hooks: true 92 hooks: true
93 }) 93 })
94 RedundancyVideos: VideoRedundancyModel[] 94 RedundancyVideos: VideoRedundancyModel[]
95 95
96 static isInfohashExists (infoHash: string) { 96 static doesInfohashExist (infoHash: string) {
97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
98 const options = { 98 const options = {
99 type: Sequelize.QueryTypes.SELECT, 99 type: Sequelize.QueryTypes.SELECT,
@@ -120,6 +120,26 @@ export class VideoFileModel extends Model<VideoFileModel> {
120 return VideoFileModel.findById(id, options) 120 return VideoFileModel.findById(id, options)
121 } 121 }
122 122
123 static async getStats () {
124 let totalLocalVideoFilesSize = await VideoFileModel.sum('size', {
125 include: [
126 {
127 attributes: [],
128 model: VideoModel.unscoped(),
129 where: {
130 remote: false
131 }
132 }
133 ]
134 } as any)
135 // Sequelize could return null...
136 if (!totalLocalVideoFilesSize) totalLocalVideoFilesSize = 0
137
138 return {
139 totalLocalVideoFilesSize
140 }
141 }
142
123 hasSameUniqueKeysThan (other: VideoFileModel) { 143 hasSameUniqueKeysThan (other: VideoFileModel) {
124 return this.fps === other.fps && 144 return this.fps === other.fps &&
125 this.resolution === other.resolution && 145 this.resolution === other.resolution &&
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index e7bff2ed7..76d0445d4 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -1,8 +1,13 @@
1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
2import { VideoModel } from './video' 2import { VideoModel } from './video'
3import { VideoFileModel } from './video-file' 3import { VideoFileModel } from './video-file'
4import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects' 4import {
5import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers' 5 ActivityPlaylistInfohashesObject,
6 ActivityPlaylistSegmentHashesObject,
7 ActivityUrlObject,
8 VideoTorrentObject
9} from '../../../shared/models/activitypub/objects'
10import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers'
6import { VideoCaptionModel } from './video-caption' 11import { VideoCaptionModel } from './video-caption'
7import { 12import {
8 getVideoCommentsActivityPubUrl, 13 getVideoCommentsActivityPubUrl,
@@ -11,6 +16,8 @@ import {
11 getVideoSharesActivityPubUrl 16 getVideoSharesActivityPubUrl
12} from '../../lib/activitypub' 17} from '../../lib/activitypub'
13import { isArray } from '../../helpers/custom-validators/misc' 18import { isArray } from '../../helpers/custom-validators/misc'
19import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
20import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
14 21
15export type VideoFormattingJSONOptions = { 22export type VideoFormattingJSONOptions = {
16 completeDescription?: boolean 23 completeDescription?: boolean
@@ -120,7 +127,12 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
120 } 127 }
121 }) 128 })
122 129
130 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
131
123 const tags = video.Tags ? video.Tags.map(t => t.name) : [] 132 const tags = video.Tags ? video.Tags.map(t => t.name) : []
133
134 const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
135
124 const detailsJson = { 136 const detailsJson = {
125 support: video.support, 137 support: video.support,
126 descriptionPath: video.getDescriptionAPIPath(), 138 descriptionPath: video.getDescriptionAPIPath(),
@@ -134,7 +146,11 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
134 id: video.state, 146 id: video.state,
135 label: VideoModel.getStateLabel(video.state) 147 label: VideoModel.getStateLabel(video.state)
136 }, 148 },
137 files: [] 149
150 trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
151
152 files: [],
153 streamingPlaylists
138 } 154 }
139 155
140 // Format and sort video files 156 // Format and sort video files
@@ -143,6 +159,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
143 return Object.assign(formattedJson, detailsJson) 159 return Object.assign(formattedJson, detailsJson)
144} 160}
145 161
162function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
163 if (isArray(playlists) === false) return []
164
165 return playlists
166 .map(playlist => {
167 const redundancies = isArray(playlist.RedundancyVideos)
168 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
169 : []
170
171 return {
172 id: playlist.id,
173 type: playlist.type,
174 playlistUrl: playlist.playlistUrl,
175 segmentsSha256Url: playlist.segmentsSha256Url,
176 redundancies
177 } as VideoStreamingPlaylist
178 })
179}
180
146function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { 181function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
147 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 182 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
148 183
@@ -208,7 +243,8 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
208 for (const file of video.VideoFiles) { 243 for (const file of video.VideoFiles) {
209 url.push({ 244 url.push({
210 type: 'Link', 245 type: 'Link',
211 mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any, 246 mimeType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
247 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
212 href: video.getVideoFileUrl(file, baseUrlHttp), 248 href: video.getVideoFileUrl(file, baseUrlHttp),
213 height: file.resolution, 249 height: file.resolution,
214 size: file.size, 250 size: file.size,
@@ -218,6 +254,7 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
218 url.push({ 254 url.push({
219 type: 'Link', 255 type: 'Link',
220 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent', 256 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
257 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
221 href: video.getTorrentUrl(file, baseUrlHttp), 258 href: video.getTorrentUrl(file, baseUrlHttp),
222 height: file.resolution 259 height: file.resolution
223 }) 260 })
@@ -225,15 +262,39 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
225 url.push({ 262 url.push({
226 type: 'Link', 263 type: 'Link',
227 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet', 264 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
265 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
228 href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs), 266 href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
229 height: file.resolution 267 height: file.resolution
230 }) 268 })
231 } 269 }
232 270
271 for (const playlist of (video.VideoStreamingPlaylists || [])) {
272 let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
273
274 tag = playlist.p2pMediaLoaderInfohashes
275 .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
276 tag.push({
277 type: 'Link',
278 name: 'sha256',
279 mimeType: 'application/json' as 'application/json',
280 mediaType: 'application/json' as 'application/json',
281 href: playlist.segmentsSha256Url
282 })
283
284 url.push({
285 type: 'Link',
286 mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
287 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
288 href: playlist.playlistUrl,
289 tag
290 })
291 }
292
233 // Add video url too 293 // Add video url too
234 url.push({ 294 url.push({
235 type: 'Link', 295 type: 'Link',
236 mimeType: 'text/html', 296 mimeType: 'text/html',
297 mediaType: 'text/html',
237 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 298 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
238 }) 299 })
239 300
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index 8d442b3f8..c723e57c0 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -144,6 +144,10 @@ export class VideoImportModel extends Model<VideoImportModel> {
144 }) 144 })
145 } 145 }
146 146
147 getTargetIdentifier () {
148 return this.targetUrl || this.magnetUri || this.torrentName
149 }
150
147 toFormattedJSON (): VideoImport { 151 toFormattedJSON (): VideoImport {
148 const videoFormatOptions = { 152 const videoFormatOptions = {
149 completeDescription: true, 153 completeDescription: true,
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index fa9a70d50..c87f71277 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -88,7 +88,7 @@ export class VideoShareModel extends Model<VideoShareModel> {
88 }) 88 })
89 Video: VideoModel 89 Video: VideoModel
90 90
91 static load (actorId: number, videoId: number, t: Sequelize.Transaction) { 91 static load (actorId: number, videoId: number, t?: Sequelize.Transaction) {
92 return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({ 92 return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({
93 where: { 93 where: {
94 actorId, 94 actorId,
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
new file mode 100644
index 000000000..bf6f7b0c4
--- /dev/null
+++ b/server/models/video/video-streaming-playlist.ts
@@ -0,0 +1,158 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
3import { throwIfNotValid } from '../utils'
4import { VideoModel } from './video'
5import * as Sequelize from 'sequelize'
6import { VideoRedundancyModel } from '../redundancy/video-redundancy'
7import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
9import { CONSTRAINTS_FIELDS, STATIC_PATHS } from '../../initializers'
10import { VideoFileModel } from './video-file'
11import { join } from 'path'
12import { sha1 } from '../../helpers/core-utils'
13import { isArrayOf } from '../../helpers/custom-validators/misc'
14
15@Table({
16 tableName: 'videoStreamingPlaylist',
17 indexes: [
18 {
19 fields: [ 'videoId' ]
20 },
21 {
22 fields: [ 'videoId', 'type' ],
23 unique: true
24 },
25 {
26 fields: [ 'p2pMediaLoaderInfohashes' ],
27 using: 'gin'
28 }
29 ]
30})
31export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistModel> {
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @AllowNull(false)
39 @Column
40 type: VideoStreamingPlaylistType
41
42 @AllowNull(false)
43 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url'))
44 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
45 playlistUrl: string
46
47 @AllowNull(false)
48 @Is('VideoStreamingPlaylistInfoHashes', value => throwIfNotValid(value, v => isArrayOf(v, isVideoFileInfoHashValid), 'info hashes'))
49 @Column(DataType.ARRAY(DataType.STRING))
50 p2pMediaLoaderInfohashes: string[]
51
52 @AllowNull(false)
53 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url'))
54 @Column
55 segmentsSha256Url: string
56
57 @ForeignKey(() => VideoModel)
58 @Column
59 videoId: number
60
61 @BelongsTo(() => VideoModel, {
62 foreignKey: {
63 allowNull: false
64 },
65 onDelete: 'CASCADE'
66 })
67 Video: VideoModel
68
69 @HasMany(() => VideoRedundancyModel, {
70 foreignKey: {
71 allowNull: false
72 },
73 onDelete: 'CASCADE',
74 hooks: true
75 })
76 RedundancyVideos: VideoRedundancyModel[]
77
78 static doesInfohashExist (infoHash: string) {
79 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
80 const options = {
81 type: Sequelize.QueryTypes.SELECT,
82 bind: { infoHash },
83 raw: true
84 }
85
86 return VideoModel.sequelize.query(query, options)
87 .then(results => {
88 return results.length === 1
89 })
90 }
91
92 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, videoFiles: VideoFileModel[]) {
93 const hashes: string[] = []
94
95 // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L97
96 for (let i = 0; i < videoFiles.length; i++) {
97 hashes.push(sha1(`1${playlistUrl}+V${i}`))
98 }
99
100 return hashes
101 }
102
103 static loadWithVideo (id: number) {
104 const options = {
105 include: [
106 {
107 model: VideoModel.unscoped(),
108 required: true
109 }
110 ]
111 }
112
113 return VideoStreamingPlaylistModel.findById(id, options)
114 }
115
116 static getHlsPlaylistFilename (resolution: number) {
117 return resolution + '.m3u8'
118 }
119
120 static getMasterHlsPlaylistFilename () {
121 return 'master.m3u8'
122 }
123
124 static getHlsSha256SegmentsFilename () {
125 return 'segments-sha256.json'
126 }
127
128 static getHlsVideoName (uuid: string, resolution: number) {
129 return `${uuid}-${resolution}-fragmented.mp4`
130 }
131
132 static getHlsMasterPlaylistStaticPath (videoUUID: string) {
133 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
134 }
135
136 static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) {
137 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
138 }
139
140 static getHlsSha256SegmentsStaticPath (videoUUID: string) {
141 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
142 }
143
144 getStringType () {
145 if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
146
147 return 'unknown'
148 }
149
150 getVideoRedundancyUrl (baseUrlHttp: string) {
151 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getStringType() + '/' + this.Video.uuid
152 }
153
154 hasSameUniqueKeysThan (other: VideoStreamingPlaylistModel) {
155 return this.type === other.type &&
156 this.videoId === other.videoId
157 }
158}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index a9baaf1da..0feeed4f8 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -27,7 +27,7 @@ import {
27 Table, 27 Table,
28 UpdatedAt 28 UpdatedAt
29} from 'sequelize-typescript' 29} from 'sequelize-typescript'
30import { VideoPrivacy, VideoState } from '../../../shared' 30import { UserRight, VideoPrivacy, VideoState } from '../../../shared'
31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
33import { VideoFilter } from '../../../shared/models/videos/video-query.type' 33import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -52,7 +52,7 @@ import {
52 ACTIVITY_PUB, 52 ACTIVITY_PUB,
53 API_VERSION, 53 API_VERSION,
54 CONFIG, 54 CONFIG,
55 CONSTRAINTS_FIELDS, 55 CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
56 PREVIEWS_SIZE, 56 PREVIEWS_SIZE,
57 REMOTE_SCHEME, 57 REMOTE_SCHEME,
58 STATIC_DOWNLOAD_PATHS, 58 STATIC_DOWNLOAD_PATHS,
@@ -70,7 +70,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
70import { ActorModel } from '../activitypub/actor' 70import { ActorModel } from '../activitypub/actor'
71import { AvatarModel } from '../avatar/avatar' 71import { AvatarModel } from '../avatar/avatar'
72import { ServerModel } from '../server/server' 72import { ServerModel } from '../server/server'
73import { buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' 73import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
74import { TagModel } from './tag' 74import { TagModel } from './tag'
75import { VideoAbuseModel } from './video-abuse' 75import { VideoAbuseModel } from './video-abuse'
76import { VideoChannelModel } from './video-channel' 76import { VideoChannelModel } from './video-channel'
@@ -93,6 +93,9 @@ import {
93} from './video-format-utils' 93} from './video-format-utils'
94import * as validator from 'validator' 94import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 95import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import'
98import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
96 99
97// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 100// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
98const indexes: Sequelize.DefineIndexesOptions[] = [ 101const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -101,17 +104,45 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
101 { fields: [ 'createdAt' ] }, 104 { fields: [ 'createdAt' ] },
102 { fields: [ 'publishedAt' ] }, 105 { fields: [ 'publishedAt' ] },
103 { fields: [ 'duration' ] }, 106 { fields: [ 'duration' ] },
104 { fields: [ 'category' ] },
105 { fields: [ 'licence' ] },
106 { fields: [ 'nsfw' ] },
107 { fields: [ 'language' ] },
108 { fields: [ 'waitTranscoding' ] },
109 { fields: [ 'state' ] },
110 { fields: [ 'remote' ] },
111 { fields: [ 'views' ] }, 107 { fields: [ 'views' ] },
112 { fields: [ 'likes' ] },
113 { fields: [ 'channelId' ] }, 108 { fields: [ 'channelId' ] },
114 { 109 {
110 fields: [ 'category' ], // We don't care videos with an unknown category
111 where: {
112 category: {
113 [Sequelize.Op.ne]: null
114 }
115 }
116 },
117 {
118 fields: [ 'licence' ], // We don't care videos with an unknown licence
119 where: {
120 licence: {
121 [Sequelize.Op.ne]: null
122 }
123 }
124 },
125 {
126 fields: [ 'language' ], // We don't care videos with an unknown language
127 where: {
128 language: {
129 [Sequelize.Op.ne]: null
130 }
131 }
132 },
133 {
134 fields: [ 'nsfw' ], // Most of the videos are not NSFW
135 where: {
136 nsfw: true
137 }
138 },
139 {
140 fields: [ 'remote' ], // Only index local videos
141 where: {
142 remote: false
143 }
144 },
145 {
115 fields: [ 'uuid' ], 146 fields: [ 'uuid' ],
116 unique: true 147 unique: true
117 }, 148 },
@@ -129,7 +160,9 @@ export enum ScopeNames {
129 WITH_FILES = 'WITH_FILES', 160 WITH_FILES = 'WITH_FILES',
130 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 161 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
131 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 162 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
132 WITH_USER_HISTORY = 'WITH_USER_HISTORY' 163 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
164 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
165 WITH_USER_ID = 'WITH_USER_ID'
133} 166}
134 167
135type ForAPIOptions = { 168type ForAPIOptions = {
@@ -138,7 +171,8 @@ type ForAPIOptions = {
138} 171}
139 172
140type AvailableForListIDsOptions = { 173type AvailableForListIDsOptions = {
141 actorId: number 174 serverAccountId: number
175 followerActorId: number
142 includeLocalVideos: boolean 176 includeLocalVideos: boolean
143 filter?: VideoFilter 177 filter?: VideoFilter
144 categoryOneOf?: number[] 178 categoryOneOf?: number[]
@@ -151,6 +185,8 @@ type AvailableForListIDsOptions = {
151 accountId?: number 185 accountId?: number
152 videoChannelId?: number 186 videoChannelId?: number
153 trendingDays?: number 187 trendingDays?: number
188 user?: UserModel,
189 historyOfUser?: UserModel
154} 190}
155 191
156@Scopes({ 192@Scopes({
@@ -236,6 +272,22 @@ type AvailableForListIDsOptions = {
236 } 272 }
237 ] 273 ]
238 }, 274 },
275 channelId: {
276 [ Sequelize.Op.notIn ]: Sequelize.literal(
277 '(' +
278 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
279 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
280 ')' +
281 ')'
282 )
283 }
284 },
285 include: []
286 }
287
288 // Only list public/published videos
289 if (!options.filter || options.filter !== 'all-local') {
290 const privacyWhere = {
239 // Always list public videos 291 // Always list public videos
240 privacy: VideoPrivacy.PUBLIC, 292 privacy: VideoPrivacy.PUBLIC,
241 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding 293 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
@@ -250,8 +302,9 @@ type AvailableForListIDsOptions = {
250 } 302 }
251 } 303 }
252 ] 304 ]
253 }, 305 }
254 include: [] 306
307 Object.assign(query.where, privacyWhere)
255 } 308 }
256 309
257 if (options.filter || options.accountId || options.videoChannelId) { 310 if (options.filter || options.accountId || options.videoChannelId) {
@@ -295,7 +348,7 @@ type AvailableForListIDsOptions = {
295 query.include.push(videoChannelInclude) 348 query.include.push(videoChannelInclude)
296 } 349 }
297 350
298 if (options.actorId) { 351 if (options.followerActorId) {
299 let localVideosReq = '' 352 let localVideosReq = ''
300 if (options.includeLocalVideos === true) { 353 if (options.includeLocalVideos === true) {
301 localVideosReq = ' UNION ALL ' + 354 localVideosReq = ' UNION ALL ' +
@@ -307,7 +360,7 @@ type AvailableForListIDsOptions = {
307 } 360 }
308 361
309 // Force actorId to be a number to avoid SQL injections 362 // Force actorId to be a number to avoid SQL injections
310 const actorIdNumber = parseInt(options.actorId.toString(), 10) 363 const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
311 query.where[ 'id' ][ Sequelize.Op.and ].push({ 364 query.where[ 'id' ][ Sequelize.Op.and ].push({
312 [ Sequelize.Op.in ]: Sequelize.literal( 365 [ Sequelize.Op.in ]: Sequelize.literal(
313 '(' + 366 '(' +
@@ -396,8 +449,39 @@ type AvailableForListIDsOptions = {
396 query.subQuery = false 449 query.subQuery = false
397 } 450 }
398 451
452 if (options.historyOfUser) {
453 query.include.push({
454 model: UserVideoHistoryModel,
455 required: true,
456 where: {
457 userId: options.historyOfUser.id
458 }
459 })
460
461 // Even if the relation is n:m, we know that a user only have 0..1 video history
462 // So we won't have multiple rows for the same video
463 // Without this, we would not be able to sort on "updatedAt" column of UserVideoHistoryModel
464 query.subQuery = false
465 }
466
399 return query 467 return query
400 }, 468 },
469 [ ScopeNames.WITH_USER_ID ]: {
470 include: [
471 {
472 attributes: [ 'accountId' ],
473 model: () => VideoChannelModel.unscoped(),
474 required: true,
475 include: [
476 {
477 attributes: [ 'userId' ],
478 model: () => AccountModel.unscoped(),
479 required: true
480 }
481 ]
482 }
483 ]
484 },
401 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { 485 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
402 include: [ 486 include: [
403 { 487 {
@@ -462,22 +546,55 @@ type AvailableForListIDsOptions = {
462 } 546 }
463 ] 547 ]
464 }, 548 },
465 [ ScopeNames.WITH_FILES ]: { 549 [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
466 include: [ 550 let subInclude: any[] = []
467 { 551
468 model: () => VideoFileModel.unscoped(), 552 if (withRedundancies === true) {
469 // FIXME: typings 553 subInclude = [
470 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join 554 {
471 required: false, 555 attributes: [ 'fileUrl' ],
472 include: [ 556 model: VideoRedundancyModel.unscoped(),
473 { 557 required: false
474 attributes: [ 'fileUrl' ], 558 }
475 model: () => VideoRedundancyModel.unscoped(), 559 ]
476 required: false 560 }
477 } 561
478 ] 562 return {
479 } 563 include: [
480 ] 564 {
565 model: VideoFileModel.unscoped(),
566 // FIXME: typings
567 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
568 required: false,
569 include: subInclude
570 }
571 ]
572 }
573 },
574 [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
575 let subInclude: any[] = []
576
577 if (withRedundancies === true) {
578 subInclude = [
579 {
580 attributes: [ 'fileUrl' ],
581 model: VideoRedundancyModel.unscoped(),
582 required: false
583 }
584 ]
585 }
586
587 return {
588 include: [
589 {
590 model: VideoStreamingPlaylistModel.unscoped(),
591 // FIXME: typings
592 [ 'separate' as any ]: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join
593 required: false,
594 include: subInclude
595 }
596 ]
597 }
481 }, 598 },
482 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { 599 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
483 include: [ 600 include: [
@@ -661,6 +778,16 @@ export class VideoModel extends Model<VideoModel> {
661 }) 778 })
662 VideoFiles: VideoFileModel[] 779 VideoFiles: VideoFileModel[]
663 780
781 @HasMany(() => VideoStreamingPlaylistModel, {
782 foreignKey: {
783 name: 'videoId',
784 allowNull: false
785 },
786 hooks: true,
787 onDelete: 'cascade'
788 })
789 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
790
664 @HasMany(() => VideoShareModel, { 791 @HasMany(() => VideoShareModel, {
665 foreignKey: { 792 foreignKey: {
666 name: 'videoId', 793 name: 'videoId',
@@ -725,6 +852,15 @@ export class VideoModel extends Model<VideoModel> {
725 }) 852 })
726 VideoBlacklist: VideoBlacklistModel 853 VideoBlacklist: VideoBlacklistModel
727 854
855 @HasOne(() => VideoImportModel, {
856 foreignKey: {
857 name: 'videoId',
858 allowNull: true
859 },
860 onDelete: 'set null'
861 })
862 VideoImport: VideoImportModel
863
728 @HasMany(() => VideoCaptionModel, { 864 @HasMany(() => VideoCaptionModel, {
729 foreignKey: { 865 foreignKey: {
730 name: 'videoId', 866 name: 'videoId',
@@ -777,6 +913,9 @@ export class VideoModel extends Model<VideoModel> {
777 tasks.push(instance.removeFile(file)) 913 tasks.push(instance.removeFile(file))
778 tasks.push(instance.removeTorrent(file)) 914 tasks.push(instance.removeTorrent(file))
779 }) 915 })
916
917 // Remove playlists file
918 tasks.push(instance.removeStreamingPlaylist())
780 } 919 }
781 920
782 // Do not wait video deletion because we could be in a transaction 921 // Do not wait video deletion because we could be in a transaction
@@ -788,8 +927,14 @@ export class VideoModel extends Model<VideoModel> {
788 return undefined 927 return undefined
789 } 928 }
790 929
791 static list () { 930 static listLocal () {
792 return VideoModel.scope(ScopeNames.WITH_FILES).findAll() 931 const query = {
932 where: {
933 remote: false
934 }
935 }
936
937 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query)
793 } 938 }
794 939
795 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { 940 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -959,10 +1104,15 @@ export class VideoModel extends Model<VideoModel> {
959 filter?: VideoFilter, 1104 filter?: VideoFilter,
960 accountId?: number, 1105 accountId?: number,
961 videoChannelId?: number, 1106 videoChannelId?: number,
962 actorId?: number 1107 followerActorId?: number
963 trendingDays?: number, 1108 trendingDays?: number,
964 userId?: number 1109 user?: UserModel,
1110 historyOfUser?: UserModel
965 }, countVideos = true) { 1111 }, countVideos = true) {
1112 if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1113 throw new Error('Try to filter all-local but no user has not the see all videos right')
1114 }
1115
966 const query: IFindOptions<VideoModel> = { 1116 const query: IFindOptions<VideoModel> = {
967 offset: options.start, 1117 offset: options.start,
968 limit: options.count, 1118 limit: options.count,
@@ -976,11 +1126,14 @@ export class VideoModel extends Model<VideoModel> {
976 query.group = 'VideoModel.id' 1126 query.group = 'VideoModel.id'
977 } 1127 }
978 1128
979 // actorId === null has a meaning, so just check undefined 1129 const serverActor = await getServerActor()
980 const actorId = options.actorId !== undefined ? options.actorId : (await getServerActor()).id 1130
1131 // followerActorId === null has a meaning, so just check undefined
1132 const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id
981 1133
982 const queryOptions = { 1134 const queryOptions = {
983 actorId, 1135 followerActorId,
1136 serverAccountId: serverActor.Account.id,
984 nsfw: options.nsfw, 1137 nsfw: options.nsfw,
985 categoryOneOf: options.categoryOneOf, 1138 categoryOneOf: options.categoryOneOf,
986 licenceOneOf: options.licenceOneOf, 1139 licenceOneOf: options.licenceOneOf,
@@ -992,7 +1145,8 @@ export class VideoModel extends Model<VideoModel> {
992 accountId: options.accountId, 1145 accountId: options.accountId,
993 videoChannelId: options.videoChannelId, 1146 videoChannelId: options.videoChannelId,
994 includeLocalVideos: options.includeLocalVideos, 1147 includeLocalVideos: options.includeLocalVideos,
995 userId: options.userId, 1148 user: options.user,
1149 historyOfUser: options.historyOfUser,
996 trendingDays 1150 trendingDays
997 } 1151 }
998 1152
@@ -1015,7 +1169,8 @@ export class VideoModel extends Model<VideoModel> {
1015 tagsAllOf?: string[] 1169 tagsAllOf?: string[]
1016 durationMin?: number // seconds 1170 durationMin?: number // seconds
1017 durationMax?: number // seconds 1171 durationMax?: number // seconds
1018 userId?: number 1172 user?: UserModel,
1173 filter?: VideoFilter
1019 }) { 1174 }) {
1020 const whereAnd = [] 1175 const whereAnd = []
1021 1176
@@ -1084,7 +1239,8 @@ export class VideoModel extends Model<VideoModel> {
1084 1239
1085 const serverActor = await getServerActor() 1240 const serverActor = await getServerActor()
1086 const queryOptions = { 1241 const queryOptions = {
1087 actorId: serverActor.id, 1242 followerActorId: serverActor.id,
1243 serverAccountId: serverActor.Account.id,
1088 includeLocalVideos: options.includeLocalVideos, 1244 includeLocalVideos: options.includeLocalVideos,
1089 nsfw: options.nsfw, 1245 nsfw: options.nsfw,
1090 categoryOneOf: options.categoryOneOf, 1246 categoryOneOf: options.categoryOneOf,
@@ -1092,7 +1248,8 @@ export class VideoModel extends Model<VideoModel> {
1092 languageOneOf: options.languageOneOf, 1248 languageOneOf: options.languageOneOf,
1093 tagsOneOf: options.tagsOneOf, 1249 tagsOneOf: options.tagsOneOf,
1094 tagsAllOf: options.tagsAllOf, 1250 tagsAllOf: options.tagsAllOf,
1095 userId: options.userId 1251 user: options.user,
1252 filter: options.filter
1096 } 1253 }
1097 1254
1098 return VideoModel.getAvailableForApi(query, queryOptions) 1255 return VideoModel.getAvailableForApi(query, queryOptions)
@@ -1108,6 +1265,16 @@ export class VideoModel extends Model<VideoModel> {
1108 return VideoModel.findOne(options) 1265 return VideoModel.findOne(options)
1109 } 1266 }
1110 1267
1268 static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
1269 const where = VideoModel.buildWhereIdOrUUID(id)
1270 const options = {
1271 where,
1272 transaction: t
1273 }
1274
1275 return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options)
1276 }
1277
1111 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1278 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
1112 const where = VideoModel.buildWhereIdOrUUID(id) 1279 const where = VideoModel.buildWhereIdOrUUID(id)
1113 1280
@@ -1120,8 +1287,8 @@ export class VideoModel extends Model<VideoModel> {
1120 return VideoModel.findOne(options) 1287 return VideoModel.findOne(options)
1121 } 1288 }
1122 1289
1123 static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) { 1290 static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
1124 return VideoModel.scope(ScopeNames.WITH_FILES) 1291 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ])
1125 .findById(id, { transaction: t, logging }) 1292 .findById(id, { transaction: t, logging })
1126 } 1293 }
1127 1294
@@ -1132,9 +1299,7 @@ export class VideoModel extends Model<VideoModel> {
1132 } 1299 }
1133 } 1300 }
1134 1301
1135 return VideoModel 1302 return VideoModel.findOne(options)
1136 .scope([ ScopeNames.WITH_FILES ])
1137 .findOne(options)
1138 } 1303 }
1139 1304
1140 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 1305 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1156,7 +1321,11 @@ export class VideoModel extends Model<VideoModel> {
1156 transaction 1321 transaction
1157 } 1322 }
1158 1323
1159 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) 1324 return VideoModel.scope([
1325 ScopeNames.WITH_ACCOUNT_DETAILS,
1326 ScopeNames.WITH_FILES,
1327 ScopeNames.WITH_STREAMING_PLAYLISTS
1328 ]).findOne(query)
1160 } 1329 }
1161 1330
1162 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1331 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
@@ -1171,9 +1340,37 @@ export class VideoModel extends Model<VideoModel> {
1171 const scopes = [ 1340 const scopes = [
1172 ScopeNames.WITH_TAGS, 1341 ScopeNames.WITH_TAGS,
1173 ScopeNames.WITH_BLACKLISTED, 1342 ScopeNames.WITH_BLACKLISTED,
1343 ScopeNames.WITH_ACCOUNT_DETAILS,
1344 ScopeNames.WITH_SCHEDULED_UPDATE,
1174 ScopeNames.WITH_FILES, 1345 ScopeNames.WITH_FILES,
1346 ScopeNames.WITH_STREAMING_PLAYLISTS
1347 ]
1348
1349 if (userId) {
1350 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
1351 }
1352
1353 return VideoModel
1354 .scope(scopes)
1355 .findOne(options)
1356 }
1357
1358 static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
1359 const where = VideoModel.buildWhereIdOrUUID(id)
1360
1361 const options = {
1362 order: [ [ 'Tags', 'name', 'ASC' ] ],
1363 where,
1364 transaction: t
1365 }
1366
1367 const scopes = [
1368 ScopeNames.WITH_TAGS,
1369 ScopeNames.WITH_BLACKLISTED,
1175 ScopeNames.WITH_ACCOUNT_DETAILS, 1370 ScopeNames.WITH_ACCOUNT_DETAILS,
1176 ScopeNames.WITH_SCHEDULED_UPDATE 1371 ScopeNames.WITH_SCHEDULED_UPDATE,
1372 { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
1373 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings
1177 ] 1374 ]
1178 1375
1179 if (userId) { 1376 if (userId) {
@@ -1217,12 +1414,31 @@ export class VideoModel extends Model<VideoModel> {
1217 }) 1414 })
1218 } 1415 }
1219 1416
1417 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1418 // Instances only share videos
1419 const query = 'SELECT 1 FROM "videoShare" ' +
1420 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1421 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1422 'LIMIT 1'
1423
1424 const options = {
1425 type: Sequelize.QueryTypes.SELECT,
1426 bind: { followerActorId, videoId },
1427 raw: true
1428 }
1429
1430 return VideoModel.sequelize.query(query, options)
1431 .then(results => results.length === 1)
1432 }
1433
1220 // threshold corresponds to how many video the field should have to be returned 1434 // threshold corresponds to how many video the field should have to be returned
1221 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { 1435 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1222 const actorId = (await getServerActor()).id 1436 const serverActor = await getServerActor()
1437 const followerActorId = serverActor.id
1223 1438
1224 const scopeOptions = { 1439 const scopeOptions: AvailableForListIDsOptions = {
1225 actorId, 1440 serverAccountId: serverActor.Account.id,
1441 followerActorId,
1226 includeLocalVideos: true 1442 includeLocalVideos: true
1227 } 1443 }
1228 1444
@@ -1256,7 +1472,7 @@ export class VideoModel extends Model<VideoModel> {
1256 } 1472 }
1257 1473
1258 private static buildActorWhereWithFilter (filter?: VideoFilter) { 1474 private static buildActorWhereWithFilter (filter?: VideoFilter) {
1259 if (filter && filter === 'local') { 1475 if (filter && (filter === 'local' || filter === 'all-local')) {
1260 return { 1476 return {
1261 serverId: null 1477 serverId: null
1262 } 1478 }
@@ -1267,7 +1483,7 @@ export class VideoModel extends Model<VideoModel> {
1267 1483
1268 private static async getAvailableForApi ( 1484 private static async getAvailableForApi (
1269 query: IFindOptions<VideoModel>, 1485 query: IFindOptions<VideoModel>,
1270 options: AvailableForListIDsOptions & { userId?: number}, 1486 options: AvailableForListIDsOptions,
1271 countVideos = true 1487 countVideos = true
1272 ) { 1488 ) {
1273 const idsScope = { 1489 const idsScope = {
@@ -1286,7 +1502,7 @@ export class VideoModel extends Model<VideoModel> {
1286 } 1502 }
1287 1503
1288 const [ count, rowsId ] = await Promise.all([ 1504 const [ count, rowsId ] = await Promise.all([
1289 countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined), 1505 countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve<number>(undefined),
1290 VideoModel.scope(idsScope).findAll(query) 1506 VideoModel.scope(idsScope).findAll(query)
1291 ]) 1507 ])
1292 const ids = rowsId.map(r => r.id) 1508 const ids = rowsId.map(r => r.id)
@@ -1300,8 +1516,8 @@ export class VideoModel extends Model<VideoModel> {
1300 } 1516 }
1301 ] 1517 ]
1302 1518
1303 if (options.userId) { 1519 if (options.user) {
1304 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.userId ] }) 1520 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1305 } 1521 }
1306 1522
1307 const secondQuery = { 1523 const secondQuery = {
@@ -1426,6 +1642,10 @@ export class VideoModel extends Model<VideoModel> {
1426 videoFile.infoHash = parsedTorrent.infoHash 1642 videoFile.infoHash = parsedTorrent.infoHash
1427 } 1643 }
1428 1644
1645 getWatchStaticPath () {
1646 return '/videos/watch/' + this.uuid
1647 }
1648
1429 getEmbedStaticPath () { 1649 getEmbedStaticPath () {
1430 return '/videos/embed/' + this.uuid 1650 return '/videos/embed/' + this.uuid
1431 } 1651 }
@@ -1483,8 +1703,10 @@ export class VideoModel extends Model<VideoModel> {
1483 .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err })) 1703 .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
1484 } 1704 }
1485 1705
1486 removeFile (videoFile: VideoFileModel) { 1706 removeFile (videoFile: VideoFileModel, isRedundancy = false) {
1487 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) 1707 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
1708
1709 const filePath = join(baseDir, this.getVideoFilename(videoFile))
1488 return remove(filePath) 1710 return remove(filePath)
1489 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err })) 1711 .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1490 } 1712 }
@@ -1495,6 +1717,14 @@ export class VideoModel extends Model<VideoModel> {
1495 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1717 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1496 } 1718 }
1497 1719
1720 removeStreamingPlaylist (isRedundancy = false) {
1721 const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_PLAYLIST_DIRECTORY
1722
1723 const filePath = join(baseDir, this.uuid)
1724 return remove(filePath)
1725 .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1726 }
1727
1498 isOutdated () { 1728 isOutdated () {
1499 if (this.isOwned()) return false 1729 if (this.isOwned()) return false
1500 1730
@@ -1506,6 +1736,12 @@ export class VideoModel extends Model<VideoModel> {
1506 (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL 1736 (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL
1507 } 1737 }
1508 1738
1739 setAsRefreshed () {
1740 this.changed('updatedAt', true)
1741
1742 return this.save()
1743 }
1744
1509 getBaseUrls () { 1745 getBaseUrls () {
1510 let baseUrlHttp 1746 let baseUrlHttp
1511 let baseUrlWs 1747 let baseUrlWs
@@ -1523,7 +1759,7 @@ export class VideoModel extends Model<VideoModel> {
1523 1759
1524 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { 1760 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1525 const xs = this.getTorrentUrl(videoFile, baseUrlHttp) 1761 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1526 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] 1762 const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1527 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] 1763 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1528 1764
1529 const redundancies = videoFile.RedundancyVideos 1765 const redundancies = videoFile.RedundancyVideos
@@ -1540,6 +1776,10 @@ export class VideoModel extends Model<VideoModel> {
1540 return magnetUtil.encode(magnetHash) 1776 return magnetUtil.encode(magnetHash)
1541 } 1777 }
1542 1778
1779 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1780 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1781 }
1782
1543 getThumbnailUrl (baseUrlHttp: string) { 1783 getThumbnailUrl (baseUrlHttp: string) {
1544 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() 1784 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1545 } 1785 }
@@ -1556,7 +1796,15 @@ export class VideoModel extends Model<VideoModel> {
1556 return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) 1796 return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1557 } 1797 }
1558 1798
1799 getVideoRedundancyUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1800 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getVideoFilename(videoFile)
1801 }
1802
1559 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1803 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1560 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) 1804 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1561 } 1805 }
1806
1807 getBandwidthBits (videoFile: VideoFileModel) {
1808 return Math.ceil((videoFile.size * 8) / this.duration)
1809 }
1562} 1810}