aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
Diffstat (limited to 'server/models')
-rw-r--r--server/models/account/account-blocklist.ts10
-rw-r--r--server/models/account/account-video-rate.ts83
-rw-r--r--server/models/account/account.ts93
-rw-r--r--server/models/account/user-notification-setting.ts22
-rw-r--r--server/models/account/user-notification.ts62
-rw-r--r--server/models/account/user-video-history.ts16
-rw-r--r--server/models/account/user.ts86
-rw-r--r--server/models/activitypub/actor-follow.ts42
-rw-r--r--server/models/activitypub/actor.ts71
-rw-r--r--server/models/application/application.ts9
-rw-r--r--server/models/avatar/avatar.ts3
-rw-r--r--server/models/migrations.ts16
-rw-r--r--server/models/oauth/oauth-token.ts20
-rw-r--r--server/models/redundancy/video-redundancy.ts192
-rw-r--r--server/models/server/server-blocklist.ts8
-rw-r--r--server/models/utils.ts88
-rw-r--r--server/models/video/schedule-video-update.ts12
-rw-r--r--server/models/video/tag.ts8
-rw-r--r--server/models/video/thumbnail.ts116
-rw-r--r--server/models/video/video-abuse.ts4
-rw-r--r--server/models/video/video-blacklist.ts49
-rw-r--r--server/models/video/video-caption.ts26
-rw-r--r--server/models/video/video-change-ownership.ts44
-rw-r--r--server/models/video/video-channel.ts122
-rw-r--r--server/models/video/video-comment.ts85
-rw-r--r--server/models/video/video-file.ts57
-rw-r--r--server/models/video/video-format-utils.ts115
-rw-r--r--server/models/video/video-import.ts16
-rw-r--r--server/models/video/video-playlist-element.ts230
-rw-r--r--server/models/video/video-playlist.ts531
-rw-r--r--server/models/video/video-share.ts27
-rw-r--r--server/models/video/video-streaming-playlist.ts172
-rw-r--r--server/models/video/video-views.ts15
-rw-r--r--server/models/video/video.ts764
34 files changed, 2467 insertions, 747 deletions
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index efd6ed59e..d5746ad76 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -8,22 +8,22 @@ enum ScopeNames {
8 WITH_ACCOUNTS = 'WITH_ACCOUNTS' 8 WITH_ACCOUNTS = 'WITH_ACCOUNTS'
9} 9}
10 10
11@Scopes({ 11@Scopes(() => ({
12 [ScopeNames.WITH_ACCOUNTS]: { 12 [ScopeNames.WITH_ACCOUNTS]: {
13 include: [ 13 include: [
14 { 14 {
15 model: () => AccountModel, 15 model: AccountModel,
16 required: true, 16 required: true,
17 as: 'ByAccount' 17 as: 'ByAccount'
18 }, 18 },
19 { 19 {
20 model: () => AccountModel, 20 model: AccountModel,
21 required: true, 21 required: true,
22 as: 'BlockedAccount' 22 as: 'BlockedAccount'
23 } 23 }
24 ] 24 ]
25 } 25 }
26}) 26}))
27 27
28@Table({ 28@Table({
29 tableName: 'accountBlocklist', 29 tableName: 'accountBlocklist',
@@ -83,7 +83,7 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
83 attributes: [ 'accountId', 'id' ], 83 attributes: [ 'accountId', 'id' ],
84 where: { 84 where: {
85 accountId: { 85 accountId: {
86 [Op.any]: accountIds 86 [Op.in]: accountIds // FIXME: sequelize ANY seems broken
87 }, 87 },
88 targetAccountId 88 targetAccountId
89 }, 89 },
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index 18762f0c5..59f586b54 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -1,14 +1,15 @@
1import { values } from 'lodash' 1import { values } from 'lodash'
2import { Transaction } from 'sequelize' 2import { FindOptions, Op, Transaction } from 'sequelize'
3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, 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'
5import { VideoRateType } from '../../../shared/models/videos' 4import { VideoRateType } from '../../../shared/models/videos'
6import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers' 5import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
7import { VideoModel } from '../video/video' 6import { VideoModel } from '../video/video'
8import { AccountModel } from './account' 7import { AccountModel } from './account'
9import { ActorModel } from '../activitypub/actor' 8import { ActorModel } from '../activitypub/actor'
10import { throwIfNotValid } from '../utils' 9import { getSort, throwIfNotValid } from '../utils'
11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 10import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
11import { AccountVideoRate } from '../../../shared'
12import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from '../video/video-channel'
12 13
13/* 14/*
14 Account rates per video. 15 Account rates per video.
@@ -38,7 +39,7 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
38export class AccountVideoRateModel extends Model<AccountVideoRateModel> { 39export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
39 40
40 @AllowNull(false) 41 @AllowNull(false)
41 @Column(DataType.ENUM(values(VIDEO_RATE_TYPES))) 42 @Column(DataType.ENUM(...values(VIDEO_RATE_TYPES)))
42 type: VideoRateType 43 type: VideoRateType
43 44
44 @AllowNull(false) 45 @AllowNull(false)
@@ -77,7 +78,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
77 Account: AccountModel 78 Account: AccountModel
78 79
79 static load (accountId: number, videoId: number, transaction?: Transaction) { 80 static load (accountId: number, videoId: number, transaction?: Transaction) {
80 const options: IFindOptions<AccountVideoRateModel> = { 81 const options: FindOptions = {
81 where: { 82 where: {
82 accountId, 83 accountId,
83 videoId 84 videoId
@@ -88,8 +89,40 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
88 return AccountVideoRateModel.findOne(options) 89 return AccountVideoRateModel.findOne(options)
89 } 90 }
90 91
92 static listByAccountForApi (options: {
93 start: number,
94 count: number,
95 sort: string,
96 type?: string,
97 accountId: number
98 }) {
99 const query: FindOptions = {
100 offset: options.start,
101 limit: options.count,
102 order: getSort(options.sort),
103 where: {
104 accountId: options.accountId
105 },
106 include: [
107 {
108 model: VideoModel,
109 required: true,
110 include: [
111 {
112 model: VideoChannelModel.scope({ method: [VideoChannelScopeNames.SUMMARY, true] }),
113 required: true
114 }
115 ]
116 }
117 ]
118 }
119 if (options.type) query.where['type'] = options.type
120
121 return AccountVideoRateModel.findAndCountAll(query)
122 }
123
91 static loadLocalAndPopulateVideo (rateType: VideoRateType, accountName: string, videoId: number, transaction?: Transaction) { 124 static loadLocalAndPopulateVideo (rateType: VideoRateType, accountName: string, videoId: number, transaction?: Transaction) {
92 const options: IFindOptions<AccountVideoRateModel> = { 125 const options: FindOptions = {
93 where: { 126 where: {
94 videoId, 127 videoId,
95 type: rateType 128 type: rateType
@@ -121,7 +154,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
121 } 154 }
122 155
123 static loadByUrl (url: string, transaction: Transaction) { 156 static loadByUrl (url: string, transaction: Transaction) {
124 const options: IFindOptions<AccountVideoRateModel> = { 157 const options: FindOptions = {
125 where: { 158 where: {
126 url 159 url
127 } 160 }
@@ -158,4 +191,38 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
158 191
159 return AccountVideoRateModel.findAndCountAll(query) 192 return AccountVideoRateModel.findAndCountAll(query)
160 } 193 }
194
195 static cleanOldRatesOf (videoId: number, type: VideoRateType, beforeUpdatedAt: Date) {
196 return AccountVideoRateModel.sequelize.transaction(async t => {
197 const query = {
198 where: {
199 updatedAt: {
200 [Op.lt]: beforeUpdatedAt
201 },
202 videoId,
203 type
204 },
205 transaction: t
206 }
207
208 const deleted = await AccountVideoRateModel.destroy(query)
209
210 const options = {
211 transaction: t,
212 where: {
213 id: videoId
214 }
215 }
216
217 if (type === 'like') await VideoModel.increment({ likes: -deleted }, options)
218 else if (type === 'dislike') await VideoModel.increment({ dislikes: -deleted }, options)
219 })
220 }
221
222 toFormattedJSON (): AccountVideoRate {
223 return {
224 video: this.Video.toFormattedJSON(),
225 rating: this.type
226 }
227 }
161} 228}
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 84ef0b30d..2b04acd86 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -1,20 +1,20 @@
1import * as Sequelize from 'sequelize'
2import { 1import {
3 AllowNull, 2 AllowNull,
4 BeforeDestroy, 3 BeforeDestroy,
5 BelongsTo, 4 BelongsTo,
6 Column, 5 Column,
7 CreatedAt, 6 CreatedAt, DataType,
8 Default, 7 Default,
9 DefaultScope, 8 DefaultScope,
10 ForeignKey, 9 ForeignKey,
11 HasMany, 10 HasMany,
12 Is, 11 Is,
13 Model, 12 Model,
13 Scopes,
14 Table, 14 Table,
15 UpdatedAt 15 UpdatedAt
16} from 'sequelize-typescript' 16} from 'sequelize-typescript'
17import { Account } from '../../../shared/models/actors' 17import { Account, AccountSummary } from '../../../shared/models/actors'
18import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 18import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
19import { sendDeleteActor } from '../../lib/activitypub/send' 19import { sendDeleteActor } from '../../lib/activitypub/send'
20import { ActorModel } from '../activitypub/actor' 20import { ActorModel } from '../activitypub/actor'
@@ -24,15 +24,49 @@ import { getSort, throwIfNotValid } from '../utils'
24import { VideoChannelModel } from '../video/video-channel' 24import { VideoChannelModel } from '../video/video-channel'
25import { VideoCommentModel } from '../video/video-comment' 25import { VideoCommentModel } from '../video/video-comment'
26import { UserModel } from './user' 26import { UserModel } from './user'
27import { AvatarModel } from '../avatar/avatar'
28import { VideoPlaylistModel } from '../video/video-playlist'
29import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
30import { Op, Transaction, WhereOptions } from 'sequelize'
27 31
28@DefaultScope({ 32export enum ScopeNames {
33 SUMMARY = 'SUMMARY'
34}
35
36@DefaultScope(() => ({
29 include: [ 37 include: [
30 { 38 {
31 model: () => ActorModel, // Default scope includes avatar and server 39 model: ActorModel, // Default scope includes avatar and server
32 required: true 40 required: true
33 } 41 }
34 ] 42 ]
35}) 43}))
44@Scopes(() => ({
45 [ ScopeNames.SUMMARY ]: (whereActor?: WhereOptions) => {
46 return {
47 attributes: [ 'id', 'name' ],
48 include: [
49 {
50 attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
51 model: ActorModel.unscoped(),
52 required: true,
53 where: whereActor,
54 include: [
55 {
56 attributes: [ 'host' ],
57 model: ServerModel.unscoped(),
58 required: false
59 },
60 {
61 model: AvatarModel.unscoped(),
62 required: false
63 }
64 ]
65 }
66 ]
67 }
68 }
69}))
36@Table({ 70@Table({
37 tableName: 'account', 71 tableName: 'account',
38 indexes: [ 72 indexes: [
@@ -56,8 +90,8 @@ export class AccountModel extends Model<AccountModel> {
56 90
57 @AllowNull(true) 91 @AllowNull(true)
58 @Default(null) 92 @Default(null)
59 @Is('AccountDescription', value => throwIfNotValid(value, isAccountDescriptionValid, 'description')) 93 @Is('AccountDescription', value => throwIfNotValid(value, isAccountDescriptionValid, 'description', true))
60 @Column 94 @Column(DataType.STRING(CONSTRAINTS_FIELDS.USERS.DESCRIPTION.max))
61 description: string 95 description: string
62 96
63 @CreatedAt 97 @CreatedAt
@@ -111,6 +145,15 @@ export class AccountModel extends Model<AccountModel> {
111 }) 145 })
112 VideoChannels: VideoChannelModel[] 146 VideoChannels: VideoChannelModel[]
113 147
148 @HasMany(() => VideoPlaylistModel, {
149 foreignKey: {
150 allowNull: false
151 },
152 onDelete: 'cascade',
153 hooks: true
154 })
155 VideoPlaylists: VideoPlaylistModel[]
156
114 @HasMany(() => VideoCommentModel, { 157 @HasMany(() => VideoCommentModel, {
115 foreignKey: { 158 foreignKey: {
116 allowNull: false 159 allowNull: false
@@ -133,8 +176,8 @@ export class AccountModel extends Model<AccountModel> {
133 return undefined 176 return undefined
134 } 177 }
135 178
136 static load (id: number, transaction?: Sequelize.Transaction) { 179 static load (id: number, transaction?: Transaction) {
137 return AccountModel.findById(id, { transaction }) 180 return AccountModel.findByPk(id, { transaction })
138 } 181 }
139 182
140 static loadByUUID (uuid: string) { 183 static loadByUUID (uuid: string) {
@@ -153,18 +196,26 @@ export class AccountModel extends Model<AccountModel> {
153 return AccountModel.findOne(query) 196 return AccountModel.findOne(query)
154 } 197 }
155 198
199 static loadByNameWithHost (nameWithHost: string) {
200 const [ accountName, host ] = nameWithHost.split('@')
201
202 if (!host || host === WEBSERVER.HOST) return AccountModel.loadLocalByName(accountName)
203
204 return AccountModel.loadByNameAndHost(accountName, host)
205 }
206
156 static loadLocalByName (name: string) { 207 static loadLocalByName (name: string) {
157 const query = { 208 const query = {
158 where: { 209 where: {
159 [ Sequelize.Op.or ]: [ 210 [ Op.or ]: [
160 { 211 {
161 userId: { 212 userId: {
162 [ Sequelize.Op.ne ]: null 213 [ Op.ne ]: null
163 } 214 }
164 }, 215 },
165 { 216 {
166 applicationId: { 217 applicationId: {
167 [ Sequelize.Op.ne ]: null 218 [ Op.ne ]: null
168 } 219 }
169 } 220 }
170 ] 221 ]
@@ -208,7 +259,7 @@ export class AccountModel extends Model<AccountModel> {
208 return AccountModel.findOne(query) 259 return AccountModel.findOne(query)
209 } 260 }
210 261
211 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 262 static loadByUrl (url: string, transaction?: Transaction) {
212 const query = { 263 const query = {
213 include: [ 264 include: [
214 { 265 {
@@ -276,6 +327,20 @@ export class AccountModel extends Model<AccountModel> {
276 return Object.assign(actor, account) 327 return Object.assign(actor, account)
277 } 328 }
278 329
330 toFormattedSummaryJSON (): AccountSummary {
331 const actor = this.Actor.toFormattedJSON()
332
333 return {
334 id: this.id,
335 uuid: actor.uuid,
336 name: actor.name,
337 displayName: this.getDisplayName(),
338 url: actor.url,
339 host: actor.host,
340 avatar: actor.avatar
341 }
342 }
343
279 toActivityPubObject () { 344 toActivityPubObject () {
280 const obj = this.Actor.toActivityPubObject(this.name, 'Account') 345 const obj = this.Actor.toActivityPubObject(this.name, 'Account')
281 346
diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts
index f1c3ac223..c2fbc6d23 100644
--- a/server/models/account/user-notification-setting.ts
+++ b/server/models/account/user-notification-setting.ts
@@ -59,6 +59,15 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
59 @AllowNull(false) 59 @AllowNull(false)
60 @Default(null) 60 @Default(null)
61 @Is( 61 @Is(
62 'UserNotificationSettingVideoAutoBlacklistAsModerator',
63 value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAutoBlacklistAsModerator')
64 )
65 @Column
66 videoAutoBlacklistAsModerator: UserNotificationSettingValue
67
68 @AllowNull(false)
69 @Default(null)
70 @Is(
62 'UserNotificationSettingBlacklistOnMyVideo', 71 'UserNotificationSettingBlacklistOnMyVideo',
63 value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo') 72 value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
64 ) 73 )
@@ -95,6 +104,15 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
95 @AllowNull(false) 104 @AllowNull(false)
96 @Default(null) 105 @Default(null)
97 @Is( 106 @Is(
107 'UserNotificationSettingNewInstanceFollower',
108 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newInstanceFollower')
109 )
110 @Column
111 newInstanceFollower: UserNotificationSettingValue
112
113 @AllowNull(false)
114 @Default(null)
115 @Is(
98 'UserNotificationSettingNewFollow', 116 'UserNotificationSettingNewFollow',
99 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow') 117 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
100 ) 118 )
@@ -139,12 +157,14 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
139 newCommentOnMyVideo: this.newCommentOnMyVideo, 157 newCommentOnMyVideo: this.newCommentOnMyVideo,
140 newVideoFromSubscription: this.newVideoFromSubscription, 158 newVideoFromSubscription: this.newVideoFromSubscription,
141 videoAbuseAsModerator: this.videoAbuseAsModerator, 159 videoAbuseAsModerator: this.videoAbuseAsModerator,
160 videoAutoBlacklistAsModerator: this.videoAutoBlacklistAsModerator,
142 blacklistOnMyVideo: this.blacklistOnMyVideo, 161 blacklistOnMyVideo: this.blacklistOnMyVideo,
143 myVideoPublished: this.myVideoPublished, 162 myVideoPublished: this.myVideoPublished,
144 myVideoImportFinished: this.myVideoImportFinished, 163 myVideoImportFinished: this.myVideoImportFinished,
145 newUserRegistration: this.newUserRegistration, 164 newUserRegistration: this.newUserRegistration,
146 commentMention: this.commentMention, 165 commentMention: this.commentMention,
147 newFollow: this.newFollow 166 newFollow: this.newFollow,
167 newInstanceFollower: this.newInstanceFollower
148 } 168 }
149 } 169 }
150} 170}
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
index 6cdbb827b..a4f97037b 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/account/user-notification.ts
@@ -1,17 +1,4 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
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' 2import { UserNotification, UserNotificationType } from '../../../shared'
16import { getSort, throwIfNotValid } from '../utils' 3import { getSort, throwIfNotValid } from '../utils'
17import { isBooleanValid } from '../../helpers/custom-validators/misc' 4import { isBooleanValid } from '../../helpers/custom-validators/misc'
@@ -19,7 +6,7 @@ import { isUserNotificationTypeValid } from '../../helpers/custom-validators/use
19import { UserModel } from './user' 6import { UserModel } from './user'
20import { VideoModel } from '../video/video' 7import { VideoModel } from '../video/video'
21import { VideoCommentModel } from '../video/video-comment' 8import { VideoCommentModel } from '../video/video-comment'
22import { Op } from 'sequelize' 9import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
23import { VideoChannelModel } from '../video/video-channel' 10import { VideoChannelModel } from '../video/video-channel'
24import { AccountModel } from './account' 11import { AccountModel } from './account'
25import { VideoAbuseModel } from '../video/video-abuse' 12import { VideoAbuseModel } from '../video/video-abuse'
@@ -37,17 +24,17 @@ enum ScopeNames {
37function buildActorWithAvatarInclude () { 24function buildActorWithAvatarInclude () {
38 return { 25 return {
39 attributes: [ 'preferredUsername' ], 26 attributes: [ 'preferredUsername' ],
40 model: () => ActorModel.unscoped(), 27 model: ActorModel.unscoped(),
41 required: true, 28 required: true,
42 include: [ 29 include: [
43 { 30 {
44 attributes: [ 'filename' ], 31 attributes: [ 'filename' ],
45 model: () => AvatarModel.unscoped(), 32 model: AvatarModel.unscoped(),
46 required: false 33 required: false
47 }, 34 },
48 { 35 {
49 attributes: [ 'host' ], 36 attributes: [ 'host' ],
50 model: () => ServerModel.unscoped(), 37 model: ServerModel.unscoped(),
51 required: false 38 required: false
52 } 39 }
53 ] 40 ]
@@ -57,7 +44,7 @@ function buildActorWithAvatarInclude () {
57function buildVideoInclude (required: boolean) { 44function buildVideoInclude (required: boolean) {
58 return { 45 return {
59 attributes: [ 'id', 'uuid', 'name' ], 46 attributes: [ 'id', 'uuid', 'name' ],
60 model: () => VideoModel.unscoped(), 47 model: VideoModel.unscoped(),
61 required 48 required
62 } 49 }
63} 50}
@@ -66,7 +53,7 @@ function buildChannelInclude (required: boolean, withActor = false) {
66 return { 53 return {
67 required, 54 required,
68 attributes: [ 'id', 'name' ], 55 attributes: [ 'id', 'name' ],
69 model: () => VideoChannelModel.unscoped(), 56 model: VideoChannelModel.unscoped(),
70 include: withActor === true ? [ buildActorWithAvatarInclude() ] : [] 57 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
71 } 58 }
72} 59}
@@ -75,12 +62,12 @@ function buildAccountInclude (required: boolean, withActor = false) {
75 return { 62 return {
76 required, 63 required,
77 attributes: [ 'id', 'name' ], 64 attributes: [ 'id', 'name' ],
78 model: () => AccountModel.unscoped(), 65 model: AccountModel.unscoped(),
79 include: withActor === true ? [ buildActorWithAvatarInclude() ] : [] 66 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
80 } 67 }
81} 68}
82 69
83@Scopes({ 70@Scopes(() => ({
84 [ScopeNames.WITH_ALL]: { 71 [ScopeNames.WITH_ALL]: {
85 include: [ 72 include: [
86 Object.assign(buildVideoInclude(false), { 73 Object.assign(buildVideoInclude(false), {
@@ -89,7 +76,7 @@ function buildAccountInclude (required: boolean, withActor = false) {
89 76
90 { 77 {
91 attributes: [ 'id', 'originCommentId' ], 78 attributes: [ 'id', 'originCommentId' ],
92 model: () => VideoCommentModel.unscoped(), 79 model: VideoCommentModel.unscoped(),
93 required: false, 80 required: false,
94 include: [ 81 include: [
95 buildAccountInclude(true, true), 82 buildAccountInclude(true, true),
@@ -99,56 +86,56 @@ function buildAccountInclude (required: boolean, withActor = false) {
99 86
100 { 87 {
101 attributes: [ 'id' ], 88 attributes: [ 'id' ],
102 model: () => VideoAbuseModel.unscoped(), 89 model: VideoAbuseModel.unscoped(),
103 required: false, 90 required: false,
104 include: [ buildVideoInclude(true) ] 91 include: [ buildVideoInclude(true) ]
105 }, 92 },
106 93
107 { 94 {
108 attributes: [ 'id' ], 95 attributes: [ 'id' ],
109 model: () => VideoBlacklistModel.unscoped(), 96 model: VideoBlacklistModel.unscoped(),
110 required: false, 97 required: false,
111 include: [ buildVideoInclude(true) ] 98 include: [ buildVideoInclude(true) ]
112 }, 99 },
113 100
114 { 101 {
115 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ], 102 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
116 model: () => VideoImportModel.unscoped(), 103 model: VideoImportModel.unscoped(),
117 required: false, 104 required: false,
118 include: [ buildVideoInclude(false) ] 105 include: [ buildVideoInclude(false) ]
119 }, 106 },
120 107
121 { 108 {
122 attributes: [ 'id' ], 109 attributes: [ 'id', 'state' ],
123 model: () => ActorFollowModel.unscoped(), 110 model: ActorFollowModel.unscoped(),
124 required: false, 111 required: false,
125 include: [ 112 include: [
126 { 113 {
127 attributes: [ 'preferredUsername' ], 114 attributes: [ 'preferredUsername' ],
128 model: () => ActorModel.unscoped(), 115 model: ActorModel.unscoped(),
129 required: true, 116 required: true,
130 as: 'ActorFollower', 117 as: 'ActorFollower',
131 include: [ 118 include: [
132 { 119 {
133 attributes: [ 'id', 'name' ], 120 attributes: [ 'id', 'name' ],
134 model: () => AccountModel.unscoped(), 121 model: AccountModel.unscoped(),
135 required: true 122 required: true
136 }, 123 },
137 { 124 {
138 attributes: [ 'filename' ], 125 attributes: [ 'filename' ],
139 model: () => AvatarModel.unscoped(), 126 model: AvatarModel.unscoped(),
140 required: false 127 required: false
141 }, 128 },
142 { 129 {
143 attributes: [ 'host' ], 130 attributes: [ 'host' ],
144 model: () => ServerModel.unscoped(), 131 model: ServerModel.unscoped(),
145 required: false 132 required: false
146 } 133 }
147 ] 134 ]
148 }, 135 },
149 { 136 {
150 attributes: [ 'preferredUsername' ], 137 attributes: [ 'preferredUsername' ],
151 model: () => ActorModel.unscoped(), 138 model: ActorModel.unscoped(),
152 required: true, 139 required: true,
153 as: 'ActorFollowing', 140 as: 'ActorFollowing',
154 include: [ 141 include: [
@@ -162,7 +149,7 @@ function buildAccountInclude (required: boolean, withActor = false) {
162 buildAccountInclude(false, true) 149 buildAccountInclude(false, true)
163 ] 150 ]
164 } 151 }
165}) 152}))
166@Table({ 153@Table({
167 tableName: 'userNotification', 154 tableName: 'userNotification',
168 indexes: [ 155 indexes: [
@@ -225,7 +212,7 @@ function buildAccountInclude (required: boolean, withActor = false) {
225 } 212 }
226 } 213 }
227 } 214 }
228 ] 215 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
229}) 216})
230export class UserNotificationModel extends Model<UserNotificationModel> { 217export class UserNotificationModel extends Model<UserNotificationModel> {
231 218
@@ -344,7 +331,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
344 ActorFollow: ActorFollowModel 331 ActorFollow: ActorFollowModel
345 332
346 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { 333 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
347 const query: IFindOptions<UserNotificationModel> = { 334 const query: FindOptions = {
348 offset: start, 335 offset: start,
349 limit: count, 336 limit: count,
350 order: getSort(sort), 337 order: getSort(sort),
@@ -370,7 +357,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
370 where: { 357 where: {
371 userId, 358 userId,
372 id: { 359 id: {
373 [Op.any]: notificationIds 360 [Op.in]: notificationIds // FIXME: sequelize ANY seems broken
374 } 361 }
375 } 362 }
376 } 363 }
@@ -418,6 +405,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
418 405
419 const actorFollow = this.ActorFollow ? { 406 const actorFollow = this.ActorFollow ? {
420 id: this.ActorFollow.id, 407 id: this.ActorFollow.id,
408 state: this.ActorFollow.state,
421 follower: { 409 follower: {
422 id: this.ActorFollow.ActorFollower.Account.id, 410 id: this.ActorFollow.ActorFollower.Account.id,
423 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(), 411 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
diff --git a/server/models/account/user-video-history.ts b/server/models/account/user-video-history.ts
index 15cb399c9..a862fc45f 100644
--- a/server/models/account/user-video-history.ts
+++ b/server/models/account/user-video-history.ts
@@ -67,7 +67,7 @@ export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> {
67 }) 67 })
68 } 68 }
69 69
70 static removeHistoryBefore (user: UserModel, beforeDate: string, t: Transaction) { 70 static removeUserHistoryBefore (user: UserModel, beforeDate: string, t: Transaction) {
71 const query: DestroyOptions = { 71 const query: DestroyOptions = {
72 where: { 72 where: {
73 userId: user.id 73 userId: user.id
@@ -76,11 +76,23 @@ export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> {
76 } 76 }
77 77
78 if (beforeDate) { 78 if (beforeDate) {
79 query.where.updatedAt = { 79 query.where['updatedAt'] = {
80 [Op.lt]: beforeDate 80 [Op.lt]: beforeDate
81 } 81 }
82 } 82 }
83 83
84 return UserVideoHistoryModel.destroy(query) 84 return UserVideoHistoryModel.destroy(query)
85 } 85 }
86
87 static removeOldHistory (beforeDate: string) {
88 const query: DestroyOptions = {
89 where: {
90 updatedAt: {
91 [Op.lt]: beforeDate
92 }
93 }
94 }
95
96 return UserVideoHistoryModel.destroy(query)
97 }
86} 98}
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 017a96657..4a9acd703 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -1,4 +1,4 @@
1import * as Sequelize from 'sequelize' 1import { FindOptions, literal, Op, QueryTypes } from 'sequelize'
2import { 2import {
3 AfterDestroy, 3 AfterDestroy,
4 AfterUpdate, 4 AfterUpdate,
@@ -22,6 +22,7 @@ import {
22import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared' 22import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared'
23import { User, UserRole } from '../../../shared/models/users' 23import { User, UserRole } from '../../../shared/models/users'
24import { 24import {
25 isUserAdminFlagsValid,
25 isUserAutoPlayVideoValid, 26 isUserAutoPlayVideoValid,
26 isUserBlockedReasonValid, 27 isUserBlockedReasonValid,
27 isUserBlockedValid, 28 isUserBlockedValid,
@@ -42,45 +43,46 @@ import { VideoChannelModel } from '../video/video-channel'
42import { AccountModel } from './account' 43import { AccountModel } from './account'
43import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type' 44import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
44import { values } from 'lodash' 45import { values } from 'lodash'
45import { NSFW_POLICY_TYPES } from '../../initializers' 46import { NSFW_POLICY_TYPES } from '../../initializers/constants'
46import { clearCacheByUserId } from '../../lib/oauth-model' 47import { clearCacheByUserId } from '../../lib/oauth-model'
47import { UserNotificationSettingModel } from './user-notification-setting' 48import { UserNotificationSettingModel } from './user-notification-setting'
48import { VideoModel } from '../video/video' 49import { VideoModel } from '../video/video'
49import { ActorModel } from '../activitypub/actor' 50import { ActorModel } from '../activitypub/actor'
50import { ActorFollowModel } from '../activitypub/actor-follow' 51import { ActorFollowModel } from '../activitypub/actor-follow'
51import { VideoImportModel } from '../video/video-import' 52import { VideoImportModel } from '../video/video-import'
53import { UserAdminFlag } from '../../../shared/models/users/user-flag.model'
52 54
53enum ScopeNames { 55enum ScopeNames {
54 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL' 56 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
55} 57}
56 58
57@DefaultScope({ 59@DefaultScope(() => ({
58 include: [ 60 include: [
59 { 61 {
60 model: () => AccountModel, 62 model: AccountModel,
61 required: true 63 required: true
62 }, 64 },
63 { 65 {
64 model: () => UserNotificationSettingModel, 66 model: UserNotificationSettingModel,
65 required: true 67 required: true
66 } 68 }
67 ] 69 ]
68}) 70}))
69@Scopes({ 71@Scopes(() => ({
70 [ScopeNames.WITH_VIDEO_CHANNEL]: { 72 [ScopeNames.WITH_VIDEO_CHANNEL]: {
71 include: [ 73 include: [
72 { 74 {
73 model: () => AccountModel, 75 model: AccountModel,
74 required: true, 76 required: true,
75 include: [ () => VideoChannelModel ] 77 include: [ VideoChannelModel ]
76 }, 78 },
77 { 79 {
78 model: () => UserNotificationSettingModel, 80 model: UserNotificationSettingModel,
79 required: true 81 required: true
80 } 82 }
81 ] 83 ]
82 } 84 }
83}) 85}))
84@Table({ 86@Table({
85 tableName: 'user', 87 tableName: 'user',
86 indexes: [ 88 indexes: [
@@ -113,13 +115,13 @@ export class UserModel extends Model<UserModel> {
113 115
114 @AllowNull(true) 116 @AllowNull(true)
115 @Default(null) 117 @Default(null)
116 @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean')) 118 @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
117 @Column 119 @Column
118 emailVerified: boolean 120 emailVerified: boolean
119 121
120 @AllowNull(false) 122 @AllowNull(false)
121 @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy')) 123 @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy'))
122 @Column(DataType.ENUM(values(NSFW_POLICY_TYPES))) 124 @Column(DataType.ENUM(...values(NSFW_POLICY_TYPES)))
123 nsfwPolicy: NSFWPolicyType 125 nsfwPolicy: NSFWPolicyType
124 126
125 @AllowNull(false) 127 @AllowNull(false)
@@ -141,6 +143,12 @@ export class UserModel extends Model<UserModel> {
141 autoPlayVideo: boolean 143 autoPlayVideo: boolean
142 144
143 @AllowNull(false) 145 @AllowNull(false)
146 @Default(UserAdminFlag.NONE)
147 @Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
148 @Column
149 adminFlags?: UserAdminFlag
150
151 @AllowNull(false)
144 @Default(false) 152 @Default(false)
145 @Is('UserBlocked', value => throwIfNotValid(value, isUserBlockedValid, 'blocked boolean')) 153 @Is('UserBlocked', value => throwIfNotValid(value, isUserBlockedValid, 'blocked boolean'))
146 @Column 154 @Column
@@ -148,7 +156,7 @@ export class UserModel extends Model<UserModel> {
148 156
149 @AllowNull(true) 157 @AllowNull(true)
150 @Default(null) 158 @Default(null)
151 @Is('UserBlockedReason', value => throwIfNotValid(value, isUserBlockedReasonValid, 'blocked reason')) 159 @Is('UserBlockedReason', value => throwIfNotValid(value, isUserBlockedReasonValid, 'blocked reason', true))
152 @Column 160 @Column
153 blockedReason: string 161 blockedReason: string
154 162
@@ -225,26 +233,26 @@ export class UserModel extends Model<UserModel> {
225 let where = undefined 233 let where = undefined
226 if (search) { 234 if (search) {
227 where = { 235 where = {
228 [Sequelize.Op.or]: [ 236 [Op.or]: [
229 { 237 {
230 email: { 238 email: {
231 [Sequelize.Op.iLike]: '%' + search + '%' 239 [Op.iLike]: '%' + search + '%'
232 } 240 }
233 }, 241 },
234 { 242 {
235 username: { 243 username: {
236 [ Sequelize.Op.iLike ]: '%' + search + '%' 244 [ Op.iLike ]: '%' + search + '%'
237 } 245 }
238 } 246 }
239 ] 247 ]
240 } 248 }
241 } 249 }
242 250
243 const query = { 251 const query: FindOptions = {
244 attributes: { 252 attributes: {
245 include: [ 253 include: [
246 [ 254 [
247 Sequelize.literal( 255 literal(
248 '(' + 256 '(' +
249 'SELECT COALESCE(SUM("size"), 0) ' + 257 'SELECT COALESCE(SUM("size"), 0) ' +
250 'FROM (' + 258 'FROM (' +
@@ -257,7 +265,7 @@ export class UserModel extends Model<UserModel> {
257 ')' 265 ')'
258 ), 266 ),
259 'videoQuotaUsed' 267 'videoQuotaUsed'
260 ] as any // FIXME: typings 268 ]
261 ] 269 ]
262 }, 270 },
263 offset: start, 271 offset: start,
@@ -283,7 +291,7 @@ export class UserModel extends Model<UserModel> {
283 const query = { 291 const query = {
284 where: { 292 where: {
285 role: { 293 role: {
286 [Sequelize.Op.in]: roles 294 [Op.in]: roles
287 } 295 }
288 } 296 }
289 } 297 }
@@ -341,7 +349,7 @@ export class UserModel extends Model<UserModel> {
341 } 349 }
342 350
343 static loadById (id: number) { 351 static loadById (id: number) {
344 return UserModel.findById(id) 352 return UserModel.findByPk(id)
345 } 353 }
346 354
347 static loadByUsername (username: string) { 355 static loadByUsername (username: string) {
@@ -379,7 +387,7 @@ export class UserModel extends Model<UserModel> {
379 387
380 const query = { 388 const query = {
381 where: { 389 where: {
382 [ Sequelize.Op.or ]: [ { username }, { email } ] 390 [ Op.or ]: [ { username }, { email } ]
383 } 391 }
384 } 392 }
385 393
@@ -502,7 +510,7 @@ export class UserModel extends Model<UserModel> {
502 const query = { 510 const query = {
503 where: { 511 where: {
504 username: { 512 username: {
505 [ Sequelize.Op.like ]: `%${search}%` 513 [ Op.like ]: `%${search}%`
506 } 514 }
507 }, 515 },
508 limit: 10 516 limit: 10
@@ -516,11 +524,15 @@ export class UserModel extends Model<UserModel> {
516 return hasUserRight(this.role, right) 524 return hasUserRight(this.role, right)
517 } 525 }
518 526
527 hasAdminFlag (flag: UserAdminFlag) {
528 return this.adminFlags & flag
529 }
530
519 isPasswordMatch (password: string) { 531 isPasswordMatch (password: string) {
520 return comparePassword(password, this.password) 532 return comparePassword(password, this.password)
521 } 533 }
522 534
523 toFormattedJSON (): User { 535 toFormattedJSON (parameters: { withAdminFlags?: boolean } = {}): User {
524 const videoQuotaUsed = this.get('videoQuotaUsed') 536 const videoQuotaUsed = this.get('videoQuotaUsed')
525 const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily') 537 const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
526 538
@@ -544,13 +556,17 @@ export class UserModel extends Model<UserModel> {
544 notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined, 556 notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined,
545 videoChannels: [], 557 videoChannels: [],
546 videoQuotaUsed: videoQuotaUsed !== undefined 558 videoQuotaUsed: videoQuotaUsed !== undefined
547 ? parseInt(videoQuotaUsed, 10) 559 ? parseInt(videoQuotaUsed + '', 10)
548 : undefined, 560 : undefined,
549 videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined 561 videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined
550 ? parseInt(videoQuotaUsedDaily, 10) 562 ? parseInt(videoQuotaUsedDaily + '', 10)
551 : undefined 563 : undefined
552 } 564 }
553 565
566 if (parameters.withAdminFlags) {
567 Object.assign(json, { adminFlags: this.adminFlags })
568 }
569
554 if (Array.isArray(this.Account.VideoChannels) === true) { 570 if (Array.isArray(this.Account.VideoChannels) === true) {
555 json.videoChannels = this.Account.VideoChannels 571 json.videoChannels = this.Account.VideoChannels
556 .map(c => c.toFormattedJSON()) 572 .map(c => c.toFormattedJSON())
@@ -575,15 +591,11 @@ export class UserModel extends Model<UserModel> {
575 591
576 const uploadedTotal = videoFile.size + totalBytes 592 const uploadedTotal = videoFile.size + totalBytes
577 const uploadedDaily = videoFile.size + totalBytesDaily 593 const uploadedDaily = videoFile.size + totalBytesDaily
578 if (this.videoQuotaDaily === -1) {
579 return uploadedTotal < this.videoQuota
580 }
581 if (this.videoQuota === -1) {
582 return uploadedDaily < this.videoQuotaDaily
583 }
584 594
585 return (uploadedTotal < this.videoQuota) && 595 if (this.videoQuotaDaily === -1) return uploadedTotal < this.videoQuota
586 (uploadedDaily < this.videoQuotaDaily) 596 if (this.videoQuota === -1) return uploadedDaily < this.videoQuotaDaily
597
598 return uploadedTotal < this.videoQuota && uploadedDaily < this.videoQuotaDaily
587 } 599 }
588 600
589 private static generateUserQuotaBaseSQL (where?: string) { 601 private static generateUserQuotaBaseSQL (where?: string) {
@@ -603,10 +615,10 @@ export class UserModel extends Model<UserModel> {
603 private static getTotalRawQuery (query: string, userId: number) { 615 private static getTotalRawQuery (query: string, userId: number) {
604 const options = { 616 const options = {
605 bind: { userId }, 617 bind: { userId },
606 type: Sequelize.QueryTypes.SELECT 618 type: QueryTypes.SELECT as QueryTypes.SELECT
607 } 619 }
608 620
609 return UserModel.sequelize.query(query, options) 621 return UserModel.sequelize.query<{ total: string }>(query, options)
610 .then(([ { total } ]) => { 622 .then(([ { total } ]) => {
611 if (total === null) return 0 623 if (total === null) return 0
612 624
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts
index 796e07a42..b0461b981 100644
--- a/server/models/activitypub/actor-follow.ts
+++ b/server/models/activitypub/actor-follow.ts
@@ -1,6 +1,5 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { values } from 'lodash' 2import { values } from 'lodash'
3import * as Sequelize from 'sequelize'
4import { 3import {
5 AfterCreate, 4 AfterCreate,
6 AfterDestroy, 5 AfterDestroy,
@@ -22,14 +21,13 @@ import { FollowState } from '../../../shared/models/actors'
22import { ActorFollow } from '../../../shared/models/actors/follow.model' 21import { ActorFollow } from '../../../shared/models/actors/follow.model'
23import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
24import { getServerActor } from '../../helpers/utils' 23import { getServerActor } from '../../helpers/utils'
25import { ACTOR_FOLLOW_SCORE } from '../../initializers' 24import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES } from '../../initializers/constants'
26import { FOLLOW_STATES } from '../../initializers/constants'
27import { ServerModel } from '../server/server' 25import { ServerModel } from '../server/server'
28import { getSort } from '../utils' 26import { getSort } from '../utils'
29import { ActorModel, unusedActorAttributesForAPI } from './actor' 27import { ActorModel, unusedActorAttributesForAPI } from './actor'
30import { VideoChannelModel } from '../video/video-channel' 28import { VideoChannelModel } from '../video/video-channel'
31import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions'
32import { AccountModel } from '../account/account' 29import { AccountModel } from '../account/account'
30import { IncludeOptions, Op, Transaction, QueryTypes } from 'sequelize'
33 31
34@Table({ 32@Table({
35 tableName: 'actorFollow', 33 tableName: 'actorFollow',
@@ -52,7 +50,7 @@ import { AccountModel } from '../account/account'
52export class ActorFollowModel extends Model<ActorFollowModel> { 50export class ActorFollowModel extends Model<ActorFollowModel> {
53 51
54 @AllowNull(false) 52 @AllowNull(false)
55 @Column(DataType.ENUM(values(FOLLOW_STATES))) 53 @Column(DataType.ENUM(...values(FOLLOW_STATES)))
56 state: FollowState 54 state: FollowState
57 55
58 @AllowNull(false) 56 @AllowNull(false)
@@ -127,7 +125,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
127 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved) 125 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
128 } 126 }
129 127
130 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Sequelize.Transaction) { 128 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction) {
131 const query = { 129 const query = {
132 where: { 130 where: {
133 actorId, 131 actorId,
@@ -151,8 +149,8 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
151 return ActorFollowModel.findOne(query) 149 return ActorFollowModel.findOne(query)
152 } 150 }
153 151
154 static loadByActorAndTargetNameAndHostForAPI (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) { 152 static loadByActorAndTargetNameAndHostForAPI (actorId: number, targetName: string, targetHost: string, t?: Transaction) {
155 const actorFollowingPartInclude: IIncludeOptions = { 153 const actorFollowingPartInclude: IncludeOptions = {
156 model: ActorModel, 154 model: ActorModel,
157 required: true, 155 required: true,
158 as: 'ActorFollowing', 156 as: 'ActorFollowing',
@@ -209,7 +207,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
209 .map(t => { 207 .map(t => {
210 if (t.host) { 208 if (t.host) {
211 return { 209 return {
212 [ Sequelize.Op.and ]: [ 210 [ Op.and ]: [
213 { 211 {
214 '$preferredUsername$': t.name 212 '$preferredUsername$': t.name
215 }, 213 },
@@ -221,7 +219,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
221 } 219 }
222 220
223 return { 221 return {
224 [ Sequelize.Op.and ]: [ 222 [ Op.and ]: [
225 { 223 {
226 '$preferredUsername$': t.name 224 '$preferredUsername$': t.name
227 }, 225 },
@@ -235,9 +233,9 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
235 const query = { 233 const query = {
236 attributes: [], 234 attributes: [],
237 where: { 235 where: {
238 [ Sequelize.Op.and ]: [ 236 [ Op.and ]: [
239 { 237 {
240 [ Sequelize.Op.or ]: whereTab 238 [ Op.or ]: whereTab
241 }, 239 },
242 { 240 {
243 actorId 241 actorId
@@ -289,7 +287,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
289 required: true, 287 required: true,
290 where: search ? { 288 where: search ? {
291 host: { 289 host: {
292 [Sequelize.Op.iLike]: '%' + search + '%' 290 [Op.iLike]: '%' + search + '%'
293 } 291 }
294 } : undefined 292 } : undefined
295 } 293 }
@@ -324,7 +322,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
324 required: true, 322 required: true,
325 where: search ? { 323 where: search ? {
326 host: { 324 host: {
327 [ Sequelize.Op.iLike ]: '%' + search + '%' 325 [ Op.iLike ]: '%' + search + '%'
328 } 326 }
329 } : undefined 327 } : undefined
330 } 328 }
@@ -407,11 +405,11 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
407 }) 405 })
408 } 406 }
409 407
410 static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { 408 static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Transaction, start?: number, count?: number) {
411 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) 409 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
412 } 410 }
413 411
414 static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Sequelize.Transaction) { 412 static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Transaction) {
415 return ActorFollowModel.createListAcceptedFollowForApiQuery( 413 return ActorFollowModel.createListAcceptedFollowForApiQuery(
416 'followers', 414 'followers',
417 actorIds, 415 actorIds,
@@ -423,7 +421,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
423 ) 421 )
424 } 422 }
425 423
426 static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { 424 static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Transaction, start?: number, count?: number) {
427 return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count) 425 return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
428 } 426 }
429 427
@@ -448,7 +446,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
448 } 446 }
449 } 447 }
450 448
451 static updateFollowScore (inboxUrl: string, value: number, t?: Sequelize.Transaction) { 449 static updateFollowScore (inboxUrl: string, value: number, t?: Transaction) {
452 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` + 450 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
453 'WHERE id IN (' + 451 'WHERE id IN (' +
454 'SELECT "actorFollow"."id" FROM "actorFollow" ' + 452 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
@@ -457,7 +455,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
457 ')' 455 ')'
458 456
459 const options = { 457 const options = {
460 type: Sequelize.QueryTypes.BULKUPDATE, 458 type: QueryTypes.BULKUPDATE,
461 transaction: t 459 transaction: t
462 } 460 }
463 461
@@ -467,7 +465,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
467 private static async createListAcceptedFollowForApiQuery ( 465 private static async createListAcceptedFollowForApiQuery (
468 type: 'followers' | 'following', 466 type: 'followers' | 'following',
469 actorIds: number[], 467 actorIds: number[],
470 t: Sequelize.Transaction, 468 t: Transaction,
471 start?: number, 469 start?: number,
472 count?: number, 470 count?: number,
473 columnUrl = 'url', 471 columnUrl = 'url',
@@ -503,7 +501,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
503 501
504 const options = { 502 const options = {
505 bind: { actorIds }, 503 bind: { actorIds },
506 type: Sequelize.QueryTypes.SELECT, 504 type: QueryTypes.SELECT,
507 transaction: t 505 transaction: t
508 } 506 }
509 tasks.push(ActorFollowModel.sequelize.query(query, options)) 507 tasks.push(ActorFollowModel.sequelize.query(query, options))
@@ -522,7 +520,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
522 const query = { 520 const query = {
523 where: { 521 where: {
524 score: { 522 score: {
525 [Sequelize.Op.lte]: 0 523 [Op.lte]: 0
526 } 524 }
527 }, 525 },
528 logging: false 526 logging: false
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index dda57a8ba..4a466441c 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -30,11 +30,11 @@ import {
30 isActorPublicKeyValid 30 isActorPublicKeyValid
31} from '../../helpers/custom-validators/activitypub/actor' 31} from '../../helpers/custom-validators/activitypub/actor'
32import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 32import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
33import { ACTIVITY_PUB, ACTIVITY_PUB_ACTOR_TYPES, CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' 33import { ACTIVITY_PUB, ACTIVITY_PUB_ACTOR_TYPES, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
34import { AccountModel } from '../account/account' 34import { AccountModel } from '../account/account'
35import { AvatarModel } from '../avatar/avatar' 35import { AvatarModel } from '../avatar/avatar'
36import { ServerModel } from '../server/server' 36import { ServerModel } from '../server/server'
37import { throwIfNotValid } from '../utils' 37import { isOutdated, throwIfNotValid } from '../utils'
38import { VideoChannelModel } from '../video/video-channel' 38import { VideoChannelModel } from '../video/video-channel'
39import { ActorFollowModel } from './actor-follow' 39import { ActorFollowModel } from './actor-follow'
40import { VideoModel } from '../video/video' 40import { VideoModel } from '../video/video'
@@ -56,46 +56,46 @@ export const unusedActorAttributesForAPI = [
56 'updatedAt' 56 'updatedAt'
57] 57]
58 58
59@DefaultScope({ 59@DefaultScope(() => ({
60 include: [ 60 include: [
61 { 61 {
62 model: () => ServerModel, 62 model: ServerModel,
63 required: false 63 required: false
64 }, 64 },
65 { 65 {
66 model: () => AvatarModel, 66 model: AvatarModel,
67 required: false 67 required: false
68 } 68 }
69 ] 69 ]
70}) 70}))
71@Scopes({ 71@Scopes(() => ({
72 [ScopeNames.FULL]: { 72 [ScopeNames.FULL]: {
73 include: [ 73 include: [
74 { 74 {
75 model: () => AccountModel.unscoped(), 75 model: AccountModel.unscoped(),
76 required: false 76 required: false
77 }, 77 },
78 { 78 {
79 model: () => VideoChannelModel.unscoped(), 79 model: VideoChannelModel.unscoped(),
80 required: false, 80 required: false,
81 include: [ 81 include: [
82 { 82 {
83 model: () => AccountModel, 83 model: AccountModel,
84 required: true 84 required: true
85 } 85 }
86 ] 86 ]
87 }, 87 },
88 { 88 {
89 model: () => ServerModel, 89 model: ServerModel,
90 required: false 90 required: false
91 }, 91 },
92 { 92 {
93 model: () => AvatarModel, 93 model: AvatarModel,
94 required: false 94 required: false
95 } 95 }
96 ] 96 ]
97 } 97 }
98}) 98}))
99@Table({ 99@Table({
100 tableName: 'actor', 100 tableName: 'actor',
101 indexes: [ 101 indexes: [
@@ -131,7 +131,7 @@ export const unusedActorAttributesForAPI = [
131export class ActorModel extends Model<ActorModel> { 131export class ActorModel extends Model<ActorModel> {
132 132
133 @AllowNull(false) 133 @AllowNull(false)
134 @Column(DataType.ENUM(values(ACTIVITY_PUB_ACTOR_TYPES))) 134 @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES)))
135 type: ActivityPubActorType 135 type: ActivityPubActorType
136 136
137 @AllowNull(false) 137 @AllowNull(false)
@@ -151,12 +151,12 @@ export class ActorModel extends Model<ActorModel> {
151 url: string 151 url: string
152 152
153 @AllowNull(true) 153 @AllowNull(true)
154 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key')) 154 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true))
155 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max)) 155 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max))
156 publicKey: string 156 publicKey: string
157 157
158 @AllowNull(true) 158 @AllowNull(true)
159 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key')) 159 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true))
160 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max)) 160 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max))
161 privateKey: string 161 privateKey: string
162 162
@@ -265,7 +265,7 @@ export class ActorModel extends Model<ActorModel> {
265 VideoChannel: VideoChannelModel 265 VideoChannel: VideoChannelModel
266 266
267 static load (id: number) { 267 static load (id: number) {
268 return ActorModel.unscoped().findById(id) 268 return ActorModel.unscoped().findByPk(id)
269 } 269 }
270 270
271 static loadAccountActorByVideoId (videoId: number, transaction: Sequelize.Transaction) { 271 static loadAccountActorByVideoId (videoId: number, transaction: Sequelize.Transaction) {
@@ -280,14 +280,16 @@ export class ActorModel extends Model<ActorModel> {
280 attributes: [ 'id' ], 280 attributes: [ 'id' ],
281 model: VideoChannelModel.unscoped(), 281 model: VideoChannelModel.unscoped(),
282 required: true, 282 required: true,
283 include: { 283 include: [
284 attributes: [ 'id' ], 284 {
285 model: VideoModel.unscoped(), 285 attributes: [ 'id' ],
286 required: true, 286 model: VideoModel.unscoped(),
287 where: { 287 required: true,
288 id: videoId 288 where: {
289 id: videoId
290 }
289 } 291 }
290 } 292 ]
291 } 293 }
292 ] 294 ]
293 } 295 }
@@ -295,7 +297,7 @@ export class ActorModel extends Model<ActorModel> {
295 transaction 297 transaction
296 } 298 }
297 299
298 return ActorModel.unscoped().findOne(query as any) // FIXME: typings 300 return ActorModel.unscoped().findOne(query)
299 } 301 }
300 302
301 static isActorUrlExist (url: string) { 303 static isActorUrlExist (url: string) {
@@ -389,8 +391,7 @@ export class ActorModel extends Model<ActorModel> {
389 } 391 }
390 392
391 static incrementFollows (id: number, column: 'followersCount' | 'followingCount', by: number) { 393 static incrementFollows (id: number, column: 'followersCount' | 'followingCount', by: number) {
392 // FIXME: typings 394 return ActorModel.increment(column, {
393 return (ActorModel as any).increment(column, {
394 by, 395 by,
395 where: { 396 where: {
396 id 397 id
@@ -444,6 +445,7 @@ export class ActorModel extends Model<ActorModel> {
444 id: this.url, 445 id: this.url,
445 following: this.getFollowingUrl(), 446 following: this.getFollowingUrl(),
446 followers: this.getFollowersUrl(), 447 followers: this.getFollowersUrl(),
448 playlists: this.getPlaylistsUrl(),
447 inbox: this.inboxUrl, 449 inbox: this.inboxUrl,
448 outbox: this.outboxUrl, 450 outbox: this.outboxUrl,
449 preferredUsername: this.preferredUsername, 451 preferredUsername: this.preferredUsername,
@@ -494,6 +496,10 @@ export class ActorModel extends Model<ActorModel> {
494 return this.url + '/followers' 496 return this.url + '/followers'
495 } 497 }
496 498
499 getPlaylistsUrl () {
500 return this.url + '/playlists'
501 }
502
497 getPublicKeyUrl () { 503 getPublicKeyUrl () {
498 return this.url + '#main-key' 504 return this.url + '#main-key'
499 } 505 }
@@ -511,7 +517,7 @@ export class ActorModel extends Model<ActorModel> {
511 } 517 }
512 518
513 getHost () { 519 getHost () {
514 return this.Server ? this.Server.host : CONFIG.WEBSERVER.HOST 520 return this.Server ? this.Server.host : WEBSERVER.HOST
515 } 521 }
516 522
517 getRedundancyAllowed () { 523 getRedundancyAllowed () {
@@ -521,17 +527,12 @@ export class ActorModel extends Model<ActorModel> {
521 getAvatarUrl () { 527 getAvatarUrl () {
522 if (!this.avatarId) return undefined 528 if (!this.avatarId) return undefined
523 529
524 return CONFIG.WEBSERVER.URL + this.Avatar.getWebserverPath() 530 return WEBSERVER.URL + this.Avatar.getWebserverPath()
525 } 531 }
526 532
527 isOutdated () { 533 isOutdated () {
528 if (this.isOwned()) return false 534 if (this.isOwned()) return false
529 535
530 const now = Date.now() 536 return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
531 const createdAtTime = this.createdAt.getTime()
532 const updatedAtTime = this.updatedAt.getTime()
533
534 return (now - createdAtTime) > ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL &&
535 (now - updatedAtTime) > ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL
536 } 537 }
537} 538}
diff --git a/server/models/application/application.ts b/server/models/application/application.ts
index 854a5fb36..81320b9af 100644
--- a/server/models/application/application.ts
+++ b/server/models/application/application.ts
@@ -1,16 +1,17 @@
1import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript' 1import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript'
2import { AccountModel } from '../account/account' 2import { AccountModel } from '../account/account'
3 3
4@DefaultScope({ 4@DefaultScope(() => ({
5 include: [ 5 include: [
6 { 6 {
7 model: () => AccountModel, 7 model: AccountModel,
8 required: true 8 required: true
9 } 9 }
10 ] 10 ]
11}) 11}))
12@Table({ 12@Table({
13 tableName: 'application' 13 tableName: 'application',
14 timestamps: false
14}) 15})
15export class ApplicationModel extends Model<ApplicationModel> { 16export class ApplicationModel extends Model<ApplicationModel> {
16 17
diff --git a/server/models/avatar/avatar.ts b/server/models/avatar/avatar.ts
index 303aebcc2..aaf1b8bd9 100644
--- a/server/models/avatar/avatar.ts
+++ b/server/models/avatar/avatar.ts
@@ -1,9 +1,10 @@
1import { join } from 'path' 1import { join } from 'path'
2import { AfterDestroy, AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AfterDestroy, AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { Avatar } from '../../../shared/models/avatars/avatar.model' 3import { Avatar } from '../../../shared/models/avatars/avatar.model'
4import { CONFIG, STATIC_PATHS } from '../../initializers' 4import { STATIC_PATHS } from '../../initializers/constants'
5import { logger } from '../../helpers/logger' 5import { logger } from '../../helpers/logger'
6import { remove } from 'fs-extra' 6import { remove } from 'fs-extra'
7import { CONFIG } from '../../initializers/config'
7 8
8@Table({ 9@Table({
9 tableName: 'avatar' 10 tableName: 'avatar'
diff --git a/server/models/migrations.ts b/server/models/migrations.ts
index 24badb166..6c11332a1 100644
--- a/server/models/migrations.ts
+++ b/server/models/migrations.ts
@@ -1,24 +1,24 @@
1import * as Sequelize from 'sequelize' 1import { ModelAttributeColumnOptions } from 'sequelize'
2 2
3declare namespace Migration { 3declare namespace Migration {
4 interface Boolean extends Sequelize.DefineAttributeColumnOptions { 4 interface Boolean extends ModelAttributeColumnOptions {
5 defaultValue: boolean | null 5 defaultValue: boolean | null
6 } 6 }
7 7
8 interface String extends Sequelize.DefineAttributeColumnOptions { 8 interface String extends ModelAttributeColumnOptions {
9 defaultValue: string | null 9 defaultValue: string | null
10 } 10 }
11 11
12 interface Integer extends Sequelize.DefineAttributeColumnOptions { 12 interface Integer extends ModelAttributeColumnOptions {
13 defaultValue: number | null 13 defaultValue: number | null
14 } 14 }
15 15
16 interface BigInteger extends Sequelize.DefineAttributeColumnOptions { 16 interface BigInteger extends ModelAttributeColumnOptions {
17 defaultValue: Sequelize.DataTypeBigInt | number | null 17 defaultValue: number | null
18 } 18 }
19 19
20 interface UUID extends Sequelize.DefineAttributeColumnOptions { 20 interface UUID extends ModelAttributeColumnOptions {
21 defaultValue: Sequelize.DataTypeUUIDv4 | null 21 defaultValue: null
22 } 22 }
23} 23}
24 24
diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts
index 08d892da4..903d551df 100644
--- a/server/models/oauth/oauth-token.ts
+++ b/server/models/oauth/oauth-token.ts
@@ -34,21 +34,21 @@ enum ScopeNames {
34 WITH_USER = 'WITH_USER' 34 WITH_USER = 'WITH_USER'
35} 35}
36 36
37@Scopes({ 37@Scopes(() => ({
38 [ScopeNames.WITH_USER]: { 38 [ScopeNames.WITH_USER]: {
39 include: [ 39 include: [
40 { 40 {
41 model: () => UserModel.unscoped(), 41 model: UserModel.unscoped(),
42 required: true, 42 required: true,
43 include: [ 43 include: [
44 { 44 {
45 attributes: [ 'id' ], 45 attributes: [ 'id' ],
46 model: () => AccountModel.unscoped(), 46 model: AccountModel.unscoped(),
47 required: true, 47 required: true,
48 include: [ 48 include: [
49 { 49 {
50 attributes: [ 'id', 'url' ], 50 attributes: [ 'id', 'url' ],
51 model: () => ActorModel.unscoped(), 51 model: ActorModel.unscoped(),
52 required: true 52 required: true
53 } 53 }
54 ] 54 ]
@@ -57,7 +57,7 @@ enum ScopeNames {
57 } 57 }
58 ] 58 ]
59 } 59 }
60}) 60}))
61@Table({ 61@Table({
62 tableName: 'oAuthToken', 62 tableName: 'oAuthToken',
63 indexes: [ 63 indexes: [
@@ -167,11 +167,13 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
167 } 167 }
168 } 168 }
169 169
170 return OAuthTokenModel.scope(ScopeNames.WITH_USER).findOne(query).then(token => { 170 return OAuthTokenModel.scope(ScopeNames.WITH_USER)
171 if (token) token['user'] = token.User 171 .findOne(query)
172 .then(token => {
173 if (token) token[ 'user' ] = token.User
172 174
173 return token 175 return token
174 }) 176 })
175 } 177 }
176 178
177 static getByRefreshTokenAndPopulateUser (refreshToken: string) { 179 static getByRefreshTokenAndPopulateUser (refreshToken: string) {
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 8f2ef2d9a..eb2222256 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -13,9 +13,9 @@ import {
13 UpdatedAt 13 UpdatedAt
14} from 'sequelize-typescript' 14} from 'sequelize-typescript'
15import { ActorModel } from '../activitypub/actor' 15import { ActorModel } from '../activitypub/actor'
16import { getVideoSort, throwIfNotValid } from '../utils' 16import { getVideoSort, parseAggregateResult, 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, MIMETYPES } from '../../initializers' 18import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
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'
@@ -27,28 +27,40 @@ import { ServerModel } from '../server/server'
27import { sample } from 'lodash' 27import { 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 { col, FindOptions, fn, literal, Op, Transaction } from 'sequelize'
31import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
32import { CONFIG } from '../../initializers/config'
31 33
32export enum ScopeNames { 34export enum ScopeNames {
33 WITH_VIDEO = 'WITH_VIDEO' 35 WITH_VIDEO = 'WITH_VIDEO'
34} 36}
35 37
36@Scopes({ 38@Scopes(() => ({
37 [ ScopeNames.WITH_VIDEO ]: { 39 [ ScopeNames.WITH_VIDEO ]: {
38 include: [ 40 include: [
39 { 41 {
40 model: () => VideoFileModel, 42 model: VideoFileModel,
41 required: true, 43 required: false,
42 include: [ 44 include: [
43 { 45 {
44 model: () => VideoModel, 46 model: VideoModel,
47 required: true
48 }
49 ]
50 },
51 {
52 model: VideoStreamingPlaylistModel,
53 required: false,
54 include: [
55 {
56 model: VideoModel,
45 required: true 57 required: true
46 } 58 }
47 ] 59 ]
48 } 60 }
49 ] 61 ]
50 } 62 }
51}) 63}))
52 64
53@Table({ 65@Table({
54 tableName: 'videoRedundancy', 66 tableName: 'videoRedundancy',
@@ -97,12 +109,24 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
97 109
98 @BelongsTo(() => VideoFileModel, { 110 @BelongsTo(() => VideoFileModel, {
99 foreignKey: { 111 foreignKey: {
100 allowNull: false 112 allowNull: true
101 }, 113 },
102 onDelete: 'cascade' 114 onDelete: 'cascade'
103 }) 115 })
104 VideoFile: VideoFileModel 116 VideoFile: VideoFileModel
105 117
118 @ForeignKey(() => VideoStreamingPlaylistModel)
119 @Column
120 videoStreamingPlaylistId: number
121
122 @BelongsTo(() => VideoStreamingPlaylistModel, {
123 foreignKey: {
124 allowNull: true
125 },
126 onDelete: 'cascade'
127 })
128 VideoStreamingPlaylist: VideoStreamingPlaylistModel
129
106 @ForeignKey(() => ActorModel) 130 @ForeignKey(() => ActorModel)
107 @Column 131 @Column
108 actorId: number 132 actorId: number
@@ -119,13 +143,25 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
119 static async removeFile (instance: VideoRedundancyModel) { 143 static async removeFile (instance: VideoRedundancyModel) {
120 if (!instance.isOwned()) return 144 if (!instance.isOwned()) return
121 145
122 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId) 146 if (instance.videoFileId) {
147 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
123 148
124 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` 149 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
125 logger.info('Removing duplicated video file %s.', logIdentifier) 150 logger.info('Removing duplicated video file %s.', logIdentifier)
126 151
127 videoFile.Video.removeFile(videoFile, true) 152 videoFile.Video.removeFile(videoFile, true)
128 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) 153 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
154 }
155
156 if (instance.videoStreamingPlaylistId) {
157 const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
158
159 const videoUUID = videoStreamingPlaylist.Video.uuid
160 logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
161
162 videoStreamingPlaylist.Video.removeStreamingPlaylist(true)
163 .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
164 }
129 165
130 return undefined 166 return undefined
131 } 167 }
@@ -143,7 +179,20 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
143 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) 179 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
144 } 180 }
145 181
146 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 182 static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number) {
183 const actor = await getServerActor()
184
185 const query = {
186 where: {
187 actorId: actor.id,
188 videoStreamingPlaylistId
189 }
190 }
191
192 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
193 }
194
195 static loadByUrl (url: string, transaction?: Transaction) {
147 const query = { 196 const query = {
148 where: { 197 where: {
149 url 198 url
@@ -191,7 +240,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
191 const ids = rows.map(r => r.id) 240 const ids = rows.map(r => r.id)
192 const id = sample(ids) 241 const id = sample(ids)
193 242
194 return VideoModel.loadWithFile(id, undefined, !isTestInstance()) 243 return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
195 } 244 }
196 245
197 static async findMostViewToDuplicate (randomizedFactor: number) { 246 static async findMostViewToDuplicate (randomizedFactor: number) {
@@ -243,7 +292,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
243 where: { 292 where: {
244 privacy: VideoPrivacy.PUBLIC, 293 privacy: VideoPrivacy.PUBLIC,
245 views: { 294 views: {
246 [ Sequelize.Op.gte ]: minViews 295 [ Op.gte ]: minViews
247 } 296 }
248 }, 297 },
249 include: [ 298 include: [
@@ -266,7 +315,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
266 actorId: actor.id, 315 actorId: actor.id,
267 strategy, 316 strategy,
268 createdAt: { 317 createdAt: {
269 [ Sequelize.Op.lt ]: expiredDate 318 [ Op.lt ]: expiredDate
270 } 319 }
271 } 320 }
272 } 321 }
@@ -277,7 +326,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
277 static async getTotalDuplicated (strategy: VideoRedundancyStrategy) { 326 static async getTotalDuplicated (strategy: VideoRedundancyStrategy) {
278 const actor = await getServerActor() 327 const actor = await getServerActor()
279 328
280 const options = { 329 const query: FindOptions = {
281 include: [ 330 include: [
282 { 331 {
283 attributes: [], 332 attributes: [],
@@ -291,12 +340,8 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
291 ] 340 ]
292 } 341 }
293 342
294 return VideoFileModel.sum('size', options as any) // FIXME: typings 343 return VideoFileModel.aggregate('size', 'SUM', query)
295 .then(v => { 344 .then(result => parseAggregateResult(result))
296 if (!v || isNaN(v)) return 0
297
298 return v
299 })
300 } 345 }
301 346
302 static async listLocalExpired () { 347 static async listLocalExpired () {
@@ -306,7 +351,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
306 where: { 351 where: {
307 actorId: actor.id, 352 actorId: actor.id,
308 expiresOn: { 353 expiresOn: {
309 [ Sequelize.Op.lt ]: new Date() 354 [ Op.lt ]: new Date()
310 } 355 }
311 } 356 }
312 } 357 }
@@ -320,10 +365,10 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
320 const query = { 365 const query = {
321 where: { 366 where: {
322 actorId: { 367 actorId: {
323 [Sequelize.Op.ne]: actor.id 368 [Op.ne]: actor.id
324 }, 369 },
325 expiresOn: { 370 expiresOn: {
326 [ Sequelize.Op.lt ]: new Date() 371 [ Op.lt ]: new Date()
327 } 372 }
328 } 373 }
329 } 374 }
@@ -333,40 +378,44 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
333 378
334 static async listLocalOfServer (serverId: number) { 379 static async listLocalOfServer (serverId: number) {
335 const actor = await getServerActor() 380 const actor = await getServerActor()
336 381 const buildVideoInclude = () => ({
337 const query = { 382 model: VideoModel,
338 where: { 383 required: true,
339 actorId: actor.id
340 },
341 include: [ 384 include: [
342 { 385 {
343 model: VideoFileModel, 386 attributes: [],
387 model: VideoChannelModel.unscoped(),
344 required: true, 388 required: true,
345 include: [ 389 include: [
346 { 390 {
347 model: VideoModel, 391 attributes: [],
392 model: ActorModel.unscoped(),
348 required: true, 393 required: true,
349 include: [ 394 where: {
350 { 395 serverId
351 attributes: [], 396 }
352 model: VideoChannelModel.unscoped(),
353 required: true,
354 include: [
355 {
356 attributes: [],
357 model: ActorModel.unscoped(),
358 required: true,
359 where: {
360 serverId
361 }
362 }
363 ]
364 }
365 ]
366 } 397 }
367 ] 398 ]
368 } 399 }
369 ] 400 ]
401 })
402
403 const query = {
404 where: {
405 actorId: actor.id
406 },
407 include: [
408 {
409 model: VideoFileModel,
410 required: false,
411 include: [ buildVideoInclude() ]
412 },
413 {
414 model: VideoStreamingPlaylistModel,
415 required: false,
416 include: [ buildVideoInclude() ]
417 }
418 ]
370 } 419 }
371 420
372 return VideoRedundancyModel.findAll(query) 421 return VideoRedundancyModel.findAll(query)
@@ -375,12 +424,12 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
375 static async getStats (strategy: VideoRedundancyStrategy) { 424 static async getStats (strategy: VideoRedundancyStrategy) {
376 const actor = await getServerActor() 425 const actor = await getServerActor()
377 426
378 const query = { 427 const query: FindOptions = {
379 raw: true, 428 raw: true,
380 attributes: [ 429 attributes: [
381 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoFile.size')), '0'), 'totalUsed' ], 430 [ fn('COALESCE', fn('SUM', col('VideoFile.size')), '0'), 'totalUsed' ],
382 [ Sequelize.fn('COUNT', Sequelize.fn('DISTINCT', Sequelize.col('videoId'))), 'totalVideos' ], 431 [ fn('COUNT', fn('DISTINCT', col('videoId'))), 'totalVideos' ],
383 [ Sequelize.fn('COUNT', Sequelize.col('videoFileId')), 'totalVideoFiles' ] 432 [ fn('COUNT', col('videoFileId')), 'totalVideoFiles' ]
384 ], 433 ],
385 where: { 434 where: {
386 strategy, 435 strategy,
@@ -395,19 +444,40 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
395 ] 444 ]
396 } 445 }
397 446
398 return VideoRedundancyModel.findOne(query as any) // FIXME: typings 447 return VideoRedundancyModel.findOne(query)
399 .then((r: any) => ({ 448 .then((r: any) => ({
400 totalUsed: parseInt(r.totalUsed.toString(), 10), 449 totalUsed: parseAggregateResult(r.totalUsed),
401 totalVideos: r.totalVideos, 450 totalVideos: r.totalVideos,
402 totalVideoFiles: r.totalVideoFiles 451 totalVideoFiles: r.totalVideoFiles
403 })) 452 }))
404 } 453 }
405 454
455 getVideo () {
456 if (this.VideoFile) return this.VideoFile.Video
457
458 return this.VideoStreamingPlaylist.Video
459 }
460
406 isOwned () { 461 isOwned () {
407 return !!this.strategy 462 return !!this.strategy
408 } 463 }
409 464
410 toActivityPubObject (): CacheFileObject { 465 toActivityPubObject (): CacheFileObject {
466 if (this.VideoStreamingPlaylist) {
467 return {
468 id: this.url,
469 type: 'CacheFile' as 'CacheFile',
470 object: this.VideoStreamingPlaylist.Video.url,
471 expires: this.expiresOn.toISOString(),
472 url: {
473 type: 'Link',
474 mimeType: 'application/x-mpegURL',
475 mediaType: 'application/x-mpegURL',
476 href: this.fileUrl
477 }
478 }
479 }
480
411 return { 481 return {
412 id: this.url, 482 id: this.url,
413 type: 'CacheFile' as 'CacheFile', 483 type: 'CacheFile' as 'CacheFile',
@@ -429,9 +499,9 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
429 private static async buildVideoFileForDuplication () { 499 private static async buildVideoFileForDuplication () {
430 const actor = await getServerActor() 500 const actor = await getServerActor()
431 501
432 const notIn = Sequelize.literal( 502 const notIn = literal(
433 '(' + 503 '(' +
434 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` + 504 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` +
435 ')' 505 ')'
436 ) 506 )
437 507
@@ -441,7 +511,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
441 required: true, 511 required: true,
442 where: { 512 where: {
443 id: { 513 id: {
444 [ Sequelize.Op.notIn ]: notIn 514 [ Op.notIn ]: notIn
445 } 515 }
446 } 516 }
447 } 517 }
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 450f27152..92c01f642 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -9,11 +9,11 @@ enum ScopeNames {
9 WITH_SERVER = 'WITH_SERVER' 9 WITH_SERVER = 'WITH_SERVER'
10} 10}
11 11
12@Scopes({ 12@Scopes(() => ({
13 [ScopeNames.WITH_ACCOUNT]: { 13 [ScopeNames.WITH_ACCOUNT]: {
14 include: [ 14 include: [
15 { 15 {
16 model: () => AccountModel, 16 model: AccountModel,
17 required: true 17 required: true
18 } 18 }
19 ] 19 ]
@@ -21,12 +21,12 @@ enum ScopeNames {
21 [ScopeNames.WITH_SERVER]: { 21 [ScopeNames.WITH_SERVER]: {
22 include: [ 22 include: [
23 { 23 {
24 model: () => ServerModel, 24 model: ServerModel,
25 required: true 25 required: true
26 } 26 }
27 ] 27 ]
28 } 28 }
29}) 29}))
30 30
31@Table({ 31@Table({
32 tableName: 'serverBlocklist', 32 tableName: 'serverBlocklist',
diff --git a/server/models/utils.ts b/server/models/utils.ts
index 5b4093aec..2b172f608 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -1,25 +1,29 @@
1import { Sequelize } from 'sequelize-typescript' 1import { Sequelize } from 'sequelize-typescript'
2import * as validator from 'validator'
3import { OrderItem } from 'sequelize'
4import { Col } from 'sequelize/types/lib/utils'
2 5
3type SortType = { sortModel: any, sortValue: string } 6type SortType = { sortModel: any, sortValue: string }
4 7
5// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ] 8// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
6function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) { 9function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
7 let { direction, field } = buildDirectionAndField(value) 10 const { direction, field } = buildDirectionAndField(value)
11
12 let finalField: string | Col
8 13
9 if (field.toLowerCase() === 'match') { // Search 14 if (field.toLowerCase() === 'match') { // Search
10 field = Sequelize.col('similarity') 15 finalField = Sequelize.col('similarity')
16 } else {
17 finalField = field
11 } 18 }
12 19
13 return [ [ field, direction ], lastSort ] 20 return [ [ finalField, direction ], lastSort ]
14} 21}
15 22
16function getVideoSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) { 23function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
17 let { direction, field } = buildDirectionAndField(value) 24 const { direction, field } = buildDirectionAndField(value)
18 25
19 // Alias 26 if (field.toLowerCase() === 'trending') { // Sort by aggregation
20 if (field.toLowerCase() === 'match') { // Search
21 field = Sequelize.col('similarity')
22 } else if (field.toLowerCase() === 'trending') { // Sort by aggregation
23 return [ 27 return [
24 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ], 28 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
25 29
@@ -29,21 +33,40 @@ function getVideoSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
29 ] 33 ]
30 } 34 }
31 35
32 const firstSort = typeof field === 'string' ? 36 let finalField: string | Col
33 field.split('.').concat([ direction ]) : 37
34 [ field, direction ] 38 // Alias
39 if (field.toLowerCase() === 'match') { // Search
40 finalField = Sequelize.col('similarity')
41 } else {
42 finalField = field
43 }
44
45 const firstSort = typeof finalField === 'string'
46 ? finalField.split('.').concat([ direction ]) as any // FIXME: sequelize typings
47 : [ finalField, direction ]
35 48
36 return [ firstSort, lastSort ] 49 return [ firstSort, lastSort ]
37} 50}
38 51
39function getSortOnModel (model: any, value: string, lastSort: string[] = [ 'id', 'ASC' ]) { 52function getSortOnModel (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
40 let [ firstSort ] = getSort(value) 53 const [ firstSort ] = getSort(value)
41 54
42 if (model) return [ [ model, firstSort[0], firstSort[1] ], lastSort ] 55 if (model) return [ [ model, firstSort[0], firstSort[1] ], lastSort ]
43 return [ firstSort, lastSort ] 56 return [ firstSort, lastSort ]
44} 57}
45 58
46function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value') { 59function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
60 const now = Date.now()
61 const createdAtTime = model.createdAt.getTime()
62 const updatedAtTime = model.updatedAt.getTime()
63
64 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
65}
66
67function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
68 if (nullable && (value === null || value === undefined)) return
69
47 if (validator(value) === false) { 70 if (validator(value) === false) {
48 throw new Error(`"${value}" is not a valid ${fieldName}.`) 71 throw new Error(`"${value}" is not a valid ${fieldName}.`)
49 } 72 }
@@ -74,13 +97,34 @@ function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number
74 97
75 const blockerIdsString = blockerIds.join(', ') 98 const blockerIdsString = blockerIds.join(', ')
76 99
77 const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' + 100 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
78 ' UNION ALL ' + 101 ' UNION ALL ' +
79 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' + 102 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
80 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' + 103 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
81 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')' 104 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
105}
106
107function buildServerIdsFollowedBy (actorId: any) {
108 const actorIdNumber = parseInt(actorId + '', 10)
109
110 return '(' +
111 'SELECT "actor"."serverId" FROM "actorFollow" ' +
112 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
113 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
114 ')'
115}
116
117function buildWhereIdOrUUID (id: number | string) {
118 return validator.isInt('' + id) ? { id } : { uuid: id }
119}
120
121function parseAggregateResult (result: any) {
122 if (!result) return 0
123
124 const total = parseInt(result + '', 10)
125 if (isNaN(total)) return 0
82 126
83 return query 127 return total
84} 128}
85 129
86// --------------------------------------------------------------------------- 130// ---------------------------------------------------------------------------
@@ -93,7 +137,11 @@ export {
93 getSortOnModel, 137 getSortOnModel,
94 createSimilarityAttribute, 138 createSimilarityAttribute,
95 throwIfNotValid, 139 throwIfNotValid,
96 buildTrigramSearchIndex 140 buildServerIdsFollowedBy,
141 buildTrigramSearchIndex,
142 buildWhereIdOrUUID,
143 isOutdated,
144 parseAggregateResult
97} 145}
98 146
99// --------------------------------------------------------------------------- 147// ---------------------------------------------------------------------------
@@ -107,7 +155,7 @@ function searchTrigramNormalizeCol (col: string) {
107} 155}
108 156
109function buildDirectionAndField (value: string) { 157function buildDirectionAndField (value: string) {
110 let field: any 158 let field: string
111 let direction: 'ASC' | 'DESC' 159 let direction: 'ASC' | 'DESC'
112 160
113 if (value.substring(0, 1) === '-') { 161 if (value.substring(0, 1) === '-') {
diff --git a/server/models/video/schedule-video-update.ts b/server/models/video/schedule-video-update.ts
index 1e56562e1..603d55692 100644
--- a/server/models/video/schedule-video-update.ts
+++ b/server/models/video/schedule-video-update.ts
@@ -1,7 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Sequelize, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { ScopeNames as VideoScopeNames, VideoModel } from './video' 2import { ScopeNames as VideoScopeNames, VideoModel } from './video'
3import { VideoPrivacy } from '../../../shared/models/videos' 3import { VideoPrivacy } from '../../../shared/models/videos'
4import { Transaction } from 'sequelize' 4import { Op, Transaction } from 'sequelize'
5 5
6@Table({ 6@Table({
7 tableName: 'scheduleVideoUpdate', 7 tableName: 'scheduleVideoUpdate',
@@ -51,7 +51,7 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
51 attributes: [ 'id' ], 51 attributes: [ 'id' ],
52 where: { 52 where: {
53 updateAt: { 53 updateAt: {
54 [Sequelize.Op.lte]: new Date() 54 [Op.lte]: new Date()
55 } 55 }
56 } 56 }
57 } 57 }
@@ -64,7 +64,7 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
64 const query = { 64 const query = {
65 where: { 65 where: {
66 updateAt: { 66 updateAt: {
67 [Sequelize.Op.lte]: new Date() 67 [Op.lte]: new Date()
68 } 68 }
69 }, 69 },
70 include: [ 70 include: [
@@ -72,7 +72,9 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
72 model: VideoModel.scope( 72 model: VideoModel.scope(
73 [ 73 [
74 VideoScopeNames.WITH_FILES, 74 VideoScopeNames.WITH_FILES,
75 VideoScopeNames.WITH_ACCOUNT_DETAILS 75 VideoScopeNames.WITH_ACCOUNT_DETAILS,
76 VideoScopeNames.WITH_BLACKLISTED,
77 VideoScopeNames.WITH_THUMBNAILS
76 ] 78 ]
77 ) 79 )
78 } 80 }
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index b39621eaf..0fc3cfd4c 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -1,5 +1,5 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import * as Sequelize from 'sequelize' 2import { QueryTypes, Transaction } from 'sequelize'
3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { isVideoTagValid } from '../../helpers/custom-validators/videos' 4import { isVideoTagValid } from '../../helpers/custom-validators/videos'
5import { throwIfNotValid } from '../utils' 5import { throwIfNotValid } from '../utils'
@@ -37,7 +37,7 @@ export class TagModel extends Model<TagModel> {
37 }) 37 })
38 Videos: VideoModel[] 38 Videos: VideoModel[]
39 39
40 static findOrCreateTags (tags: string[], transaction: Sequelize.Transaction) { 40 static findOrCreateTags (tags: string[], transaction: Transaction) {
41 if (tags === null) return [] 41 if (tags === null) return []
42 42
43 const tasks: Bluebird<TagModel>[] = [] 43 const tasks: Bluebird<TagModel>[] = []
@@ -72,10 +72,10 @@ export class TagModel extends Model<TagModel> {
72 72
73 const options = { 73 const options = {
74 bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED }, 74 bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED },
75 type: Sequelize.QueryTypes.SELECT 75 type: QueryTypes.SELECT as QueryTypes.SELECT
76 } 76 }
77 77
78 return TagModel.sequelize.query(query, options) 78 return TagModel.sequelize.query<{ name: string }>(query, options)
79 .then(data => data.map(d => d.name)) 79 .then(data => data.map(d => d.name))
80 } 80 }
81} 81}
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
new file mode 100644
index 000000000..206e9a3d6
--- /dev/null
+++ b/server/models/video/thumbnail.ts
@@ -0,0 +1,116 @@
1import { join } from 'path'
2import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
4import { logger } from '../../helpers/logger'
5import { remove } from 'fs-extra'
6import { CONFIG } from '../../initializers/config'
7import { VideoModel } from './video'
8import { VideoPlaylistModel } from './video-playlist'
9import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
10
11@Table({
12 tableName: 'thumbnail',
13 indexes: [
14 {
15 fields: [ 'videoId' ]
16 },
17 {
18 fields: [ 'videoPlaylistId' ],
19 unique: true
20 }
21 ]
22})
23export class ThumbnailModel extends Model<ThumbnailModel> {
24
25 @AllowNull(false)
26 @Column
27 filename: string
28
29 @AllowNull(true)
30 @Default(null)
31 @Column
32 height: number
33
34 @AllowNull(true)
35 @Default(null)
36 @Column
37 width: number
38
39 @AllowNull(false)
40 @Column
41 type: ThumbnailType
42
43 @AllowNull(true)
44 @Column
45 fileUrl: string
46
47 @ForeignKey(() => VideoModel)
48 @Column
49 videoId: number
50
51 @BelongsTo(() => VideoModel, {
52 foreignKey: {
53 allowNull: true
54 },
55 onDelete: 'CASCADE'
56 })
57 Video: VideoModel
58
59 @ForeignKey(() => VideoPlaylistModel)
60 @Column
61 videoPlaylistId: number
62
63 @BelongsTo(() => VideoPlaylistModel, {
64 foreignKey: {
65 allowNull: true
66 },
67 onDelete: 'CASCADE'
68 })
69 VideoPlaylist: VideoPlaylistModel
70
71 @CreatedAt
72 createdAt: Date
73
74 @UpdatedAt
75 updatedAt: Date
76
77 private static types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = {
78 [ThumbnailType.MINIATURE]: {
79 label: 'miniature',
80 directory: CONFIG.STORAGE.THUMBNAILS_DIR,
81 staticPath: STATIC_PATHS.THUMBNAILS
82 },
83 [ThumbnailType.PREVIEW]: {
84 label: 'preview',
85 directory: CONFIG.STORAGE.PREVIEWS_DIR,
86 staticPath: STATIC_PATHS.PREVIEWS
87 }
88 }
89
90 @AfterDestroy
91 static removeFilesAndSendDelete (instance: ThumbnailModel) {
92 logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename)
93
94 // Don't block the transaction
95 instance.removeThumbnail()
96 .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, err))
97 }
98
99 static generateDefaultPreviewName (videoUUID: string) {
100 return videoUUID + '.jpg'
101 }
102
103 getFileUrl () {
104 if (this.fileUrl) return this.fileUrl
105
106 const staticPath = ThumbnailModel.types[this.type].staticPath
107 return WEBSERVER.URL + staticPath + this.filename
108 }
109
110 removeThumbnail () {
111 const directory = ThumbnailModel.types[this.type].directory
112 const thumbnailPath = join(directory, this.filename)
113
114 return remove(thumbnailPath)
115 }
116}
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index cc47644f2..1ac7919b3 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -10,7 +10,7 @@ import { AccountModel } from '../account/account'
10import { getSort, throwIfNotValid } from '../utils' 10import { getSort, throwIfNotValid } from '../utils'
11import { VideoModel } from './video' 11import { VideoModel } from './video'
12import { VideoAbuseState } from '../../../shared' 12import { VideoAbuseState } from '../../../shared'
13import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers' 13import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
14 14
15@Table({ 15@Table({
16 tableName: 'videoAbuse', 16 tableName: 'videoAbuse',
@@ -39,7 +39,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
39 39
40 @AllowNull(true) 40 @AllowNull(true)
41 @Default(null) 41 @Default(null)
42 @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment')) 42 @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment', true))
43 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max)) 43 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max))
44 moderationComment: string 44 moderationComment: string
45 45
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 3b567e488..d9fe9dfc9 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,9 +1,11 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { getSortOnModel, SortType, throwIfNotValid } from '../utils' 2import { getSortOnModel, SortType, throwIfNotValid } from '../utils'
3import { VideoModel } from './video' 3import { VideoModel } from './video'
4import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' 4import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
5import { VideoBlacklist } from '../../../shared/models/videos' 5import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
6import { CONSTRAINTS_FIELDS } from '../../initializers' 6import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
8import { FindOptions } from 'sequelize'
7 9
8@Table({ 10@Table({
9 tableName: 'videoBlacklist', 11 tableName: 'videoBlacklist',
@@ -17,7 +19,7 @@ import { CONSTRAINTS_FIELDS } from '../../initializers'
17export class VideoBlacklistModel extends Model<VideoBlacklistModel> { 19export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
18 20
19 @AllowNull(true) 21 @AllowNull(true)
20 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason')) 22 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason', true))
21 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max)) 23 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max))
22 reason: string 24 reason: string
23 25
@@ -25,6 +27,12 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
25 @Column 27 @Column
26 unfederated: boolean 28 unfederated: boolean
27 29
30 @AllowNull(false)
31 @Default(null)
32 @Is('VideoBlacklistType', value => throwIfNotValid(value, isVideoBlacklistTypeValid, 'type'))
33 @Column
34 type: VideoBlacklistType
35
28 @CreatedAt 36 @CreatedAt
29 createdAt: Date 37 createdAt: Date
30 38
@@ -43,19 +51,29 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
43 }) 51 })
44 Video: VideoModel 52 Video: VideoModel
45 53
46 static listForApi (start: number, count: number, sort: SortType) { 54 static listForApi (start: number, count: number, sort: SortType, type?: VideoBlacklistType) {
47 const query = { 55 const query: FindOptions = {
48 offset: start, 56 offset: start,
49 limit: count, 57 limit: count,
50 order: getSortOnModel(sort.sortModel, sort.sortValue), 58 order: getSortOnModel(sort.sortModel, sort.sortValue),
51 include: [ 59 include: [
52 { 60 {
53 model: VideoModel, 61 model: VideoModel,
54 required: true 62 required: true,
63 include: [
64 {
65 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }),
66 required: true
67 }
68 ]
55 } 69 }
56 ] 70 ]
57 } 71 }
58 72
73 if (type) {
74 query.where = { type }
75 }
76
59 return VideoBlacklistModel.findAndCountAll(query) 77 return VideoBlacklistModel.findAndCountAll(query)
60 .then(({ rows, count }) => { 78 .then(({ rows, count }) => {
61 return { 79 return {
@@ -76,26 +94,15 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
76 } 94 }
77 95
78 toFormattedJSON (): VideoBlacklist { 96 toFormattedJSON (): VideoBlacklist {
79 const video = this.Video
80
81 return { 97 return {
82 id: this.id, 98 id: this.id,
83 createdAt: this.createdAt, 99 createdAt: this.createdAt,
84 updatedAt: this.updatedAt, 100 updatedAt: this.updatedAt,
85 reason: this.reason, 101 reason: this.reason,
86 unfederated: this.unfederated, 102 unfederated: this.unfederated,
103 type: this.type,
87 104
88 video: { 105 video: this.Video.toFormattedJSON()
89 id: video.id,
90 name: video.name,
91 uuid: video.uuid,
92 description: video.description,
93 duration: video.duration,
94 views: video.views,
95 likes: video.likes,
96 dislikes: video.dislikes,
97 nsfw: video.nsfw
98 }
99 } 106 }
100 } 107 }
101} 108}
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index b4f17b481..76243bf48 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -1,4 +1,4 @@
1import * as Sequelize from 'sequelize' 1import { OrderItem, Transaction } from 'sequelize'
2import { 2import {
3 AllowNull, 3 AllowNull,
4 BeforeDestroy, 4 BeforeDestroy,
@@ -12,30 +12,31 @@ import {
12 Table, 12 Table,
13 UpdatedAt 13 UpdatedAt
14} from 'sequelize-typescript' 14} from 'sequelize-typescript'
15import { throwIfNotValid } from '../utils' 15import { buildWhereIdOrUUID, throwIfNotValid } from '../utils'
16import { VideoModel } from './video' 16import { VideoModel } from './video'
17import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' 17import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
18import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' 18import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
19import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers' 19import { STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers/constants'
20import { join } from 'path' 20import { join } from 'path'
21import { logger } from '../../helpers/logger' 21import { logger } from '../../helpers/logger'
22import { remove } from 'fs-extra' 22import { remove } from 'fs-extra'
23import { CONFIG } from '../../initializers/config'
23 24
24export enum ScopeNames { 25export enum ScopeNames {
25 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' 26 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
26} 27}
27 28
28@Scopes({ 29@Scopes(() => ({
29 [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: { 30 [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
30 include: [ 31 include: [
31 { 32 {
32 attributes: [ 'uuid', 'remote' ], 33 attributes: [ 'uuid', 'remote' ],
33 model: () => VideoModel.unscoped(), 34 model: VideoModel.unscoped(),
34 required: true 35 required: true
35 } 36 }
36 ] 37 ]
37 } 38 }
38}) 39}))
39 40
40@Table({ 41@Table({
41 tableName: 'videoCaption', 42 tableName: 'videoCaption',
@@ -96,12 +97,9 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> {
96 const videoInclude = { 97 const videoInclude = {
97 model: VideoModel.unscoped(), 98 model: VideoModel.unscoped(),
98 attributes: [ 'id', 'remote', 'uuid' ], 99 attributes: [ 'id', 'remote', 'uuid' ],
99 where: { } 100 where: buildWhereIdOrUUID(videoId)
100 } 101 }
101 102
102 if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId
103 else videoInclude.where['id'] = videoId
104
105 const query = { 103 const query = {
106 where: { 104 where: {
107 language 105 language
@@ -114,19 +112,19 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> {
114 return VideoCaptionModel.findOne(query) 112 return VideoCaptionModel.findOne(query)
115 } 113 }
116 114
117 static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) { 115 static insertOrReplaceLanguage (videoId: number, language: string, transaction: Transaction) {
118 const values = { 116 const values = {
119 videoId, 117 videoId,
120 language 118 language
121 } 119 }
122 120
123 return VideoCaptionModel.upsert<VideoCaptionModel>(values, { transaction, returning: true }) 121 return (VideoCaptionModel.upsert<VideoCaptionModel>(values, { transaction, returning: true }) as any) // FIXME: typings
124 .then(([ caption ]) => caption) 122 .then(([ caption ]) => caption)
125 } 123 }
126 124
127 static listVideoCaptions (videoId: number) { 125 static listVideoCaptions (videoId: number) {
128 const query = { 126 const query = {
129 order: [ [ 'language', 'ASC' ] ], 127 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
130 where: { 128 where: {
131 videoId 129 videoId
132 } 130 }
@@ -139,7 +137,7 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> {
139 return VIDEO_LANGUAGES[language] || 'Unknown' 137 return VIDEO_LANGUAGES[language] || 'Unknown'
140 } 138 }
141 139
142 static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) { 140 static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Transaction) {
143 const query = { 141 const query = {
144 where: { 142 where: {
145 videoId 143 videoId
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 48c07728f..b545a2f8c 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -1,51 +1,52 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { AccountModel } from '../account/account' 2import { AccountModel } from '../account/account'
3import { VideoModel } from './video' 3import { ScopeNames as VideoScopeNames, VideoModel } from './video'
4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' 4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
5import { getSort } from '../utils' 5import { getSort } from '../utils'
6import { VideoFileModel } from './video-file'
7 6
8enum ScopeNames { 7enum ScopeNames {
9 FULL = 'FULL' 8 WITH_ACCOUNTS = 'WITH_ACCOUNTS',
9 WITH_VIDEO = 'WITH_VIDEO'
10} 10}
11 11
12@Table({ 12@Table({
13 tableName: 'videoChangeOwnership', 13 tableName: 'videoChangeOwnership',
14 indexes: [ 14 indexes: [
15 { 15 {
16 fields: ['videoId'] 16 fields: [ 'videoId' ]
17 }, 17 },
18 { 18 {
19 fields: ['initiatorAccountId'] 19 fields: [ 'initiatorAccountId' ]
20 }, 20 },
21 { 21 {
22 fields: ['nextOwnerAccountId'] 22 fields: [ 'nextOwnerAccountId' ]
23 } 23 }
24 ] 24 ]
25}) 25})
26@Scopes({ 26@Scopes(() => ({
27 [ScopeNames.FULL]: { 27 [ScopeNames.WITH_ACCOUNTS]: {
28 include: [ 28 include: [
29 { 29 {
30 model: () => AccountModel, 30 model: AccountModel,
31 as: 'Initiator', 31 as: 'Initiator',
32 required: true 32 required: true
33 }, 33 },
34 { 34 {
35 model: () => AccountModel, 35 model: AccountModel,
36 as: 'NextOwner', 36 as: 'NextOwner',
37 required: true 37 required: true
38 }, 38 }
39 ]
40 },
41 [ScopeNames.WITH_VIDEO]: {
42 include: [
39 { 43 {
40 model: () => VideoModel, 44 model: VideoModel.scope([ VideoScopeNames.WITH_THUMBNAILS, VideoScopeNames.WITH_FILES ]),
41 required: true, 45 required: true
42 include: [
43 { model: () => VideoFileModel }
44 ]
45 } 46 }
46 ] 47 ]
47 } 48 }
48}) 49}))
49export class VideoChangeOwnershipModel extends Model<VideoChangeOwnershipModel> { 50export class VideoChangeOwnershipModel extends Model<VideoChangeOwnershipModel> {
50 @CreatedAt 51 @CreatedAt
51 createdAt: Date 52 createdAt: Date
@@ -105,12 +106,15 @@ export class VideoChangeOwnershipModel extends Model<VideoChangeOwnershipModel>
105 } 106 }
106 } 107 }
107 108
108 return VideoChangeOwnershipModel.scope(ScopeNames.FULL).findAndCountAll(query) 109 return Promise.all([
109 .then(({ rows, count }) => ({ total: count, data: rows })) 110 VideoChangeOwnershipModel.scope(ScopeNames.WITH_ACCOUNTS).count(query),
111 VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ]).findAll(query)
112 ]).then(([ count, rows ]) => ({ total: count, data: rows }))
110 } 113 }
111 114
112 static load (id: number) { 115 static load (id: number) {
113 return VideoChangeOwnershipModel.scope(ScopeNames.FULL).findById(id) 116 return VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ])
117 .findByPk(id)
114 } 118 }
115 119
116 toFormattedJSON (): VideoChangeOwnership { 120 toFormattedJSON (): VideoChangeOwnership {
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 5598d80f6..fb70e6625 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -17,23 +17,25 @@ import {
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { ActivityPubActor } from '../../../shared/models/activitypub' 19import { ActivityPubActor } from '../../../shared/models/activitypub'
20import { VideoChannel } from '../../../shared/models/videos' 20import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
21import { 21import {
22 isVideoChannelDescriptionValid, 22 isVideoChannelDescriptionValid,
23 isVideoChannelNameValid, 23 isVideoChannelNameValid,
24 isVideoChannelSupportValid 24 isVideoChannelSupportValid
25} from '../../helpers/custom-validators/video-channels' 25} from '../../helpers/custom-validators/video-channels'
26import { sendDeleteActor } from '../../lib/activitypub/send' 26import { sendDeleteActor } from '../../lib/activitypub/send'
27import { AccountModel } from '../account/account' 27import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
29import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 29import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
30import { VideoModel } from './video' 30import { VideoModel } from './video'
31import { CONSTRAINTS_FIELDS } from '../../initializers' 31import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32import { ServerModel } from '../server/server' 32import { ServerModel } from '../server/server'
33import { DefineIndexesOptions } from 'sequelize' 33import { FindOptions, ModelIndexesOptions, Op } from 'sequelize'
34import { AvatarModel } from '../avatar/avatar'
35import { VideoPlaylistModel } from './video-playlist'
34 36
35// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 37// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
36const indexes: DefineIndexesOptions[] = [ 38const indexes: ModelIndexesOptions[] = [
37 buildTrigramSearchIndex('video_channel_name_trigram', 'name'), 39 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
38 40
39 { 41 {
@@ -44,35 +46,62 @@ const indexes: DefineIndexesOptions[] = [
44 } 46 }
45] 47]
46 48
47enum ScopeNames { 49export enum ScopeNames {
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 50 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
49 WITH_ACCOUNT = 'WITH_ACCOUNT', 51 WITH_ACCOUNT = 'WITH_ACCOUNT',
50 WITH_ACTOR = 'WITH_ACTOR', 52 WITH_ACTOR = 'WITH_ACTOR',
51 WITH_VIDEOS = 'WITH_VIDEOS' 53 WITH_VIDEOS = 'WITH_VIDEOS',
54 SUMMARY = 'SUMMARY'
52} 55}
53 56
54type AvailableForListOptions = { 57type AvailableForListOptions = {
55 actorId: number 58 actorId: number
56} 59}
57 60
58@DefaultScope({ 61@DefaultScope(() => ({
59 include: [ 62 include: [
60 { 63 {
61 model: () => ActorModel, 64 model: ActorModel,
62 required: true 65 required: true
63 } 66 }
64 ] 67 ]
65}) 68}))
66@Scopes({ 69@Scopes(() => ({
67 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { 70 [ScopeNames.SUMMARY]: (withAccount = false) => {
68 const actorIdNumber = parseInt(options.actorId + '', 10) 71 const base: FindOptions = {
72 attributes: [ 'name', 'description', 'id', 'actorId' ],
73 include: [
74 {
75 attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
76 model: ActorModel.unscoped(),
77 required: true,
78 include: [
79 {
80 attributes: [ 'host' ],
81 model: ServerModel.unscoped(),
82 required: false
83 },
84 {
85 model: AvatarModel.unscoped(),
86 required: false
87 }
88 ]
89 }
90 ]
91 }
92
93 if (withAccount === true) {
94 base.include.push({
95 model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
96 required: true
97 })
98 }
69 99
100 return base
101 },
102 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
70 // Only list local channels OR channels that are on an instance followed by actorId 103 // Only list local channels OR channels that are on an instance followed by actorId
71 const inQueryInstanceFollow = '(' + 104 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
72 'SELECT "actor"."serverId" FROM "actorFollow" ' +
73 'INNER JOIN "actor" ON actor.id= "actorFollow"."targetActorId" ' +
74 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
75 ')'
76 105
77 return { 106 return {
78 include: [ 107 include: [
@@ -82,13 +111,13 @@ type AvailableForListOptions = {
82 }, 111 },
83 model: ActorModel, 112 model: ActorModel,
84 where: { 113 where: {
85 [Sequelize.Op.or]: [ 114 [Op.or]: [
86 { 115 {
87 serverId: null 116 serverId: null
88 }, 117 },
89 { 118 {
90 serverId: { 119 serverId: {
91 [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow) 120 [ Op.in ]: Sequelize.literal(inQueryInstanceFollow)
92 } 121 }
93 } 122 }
94 ] 123 ]
@@ -113,22 +142,22 @@ type AvailableForListOptions = {
113 [ScopeNames.WITH_ACCOUNT]: { 142 [ScopeNames.WITH_ACCOUNT]: {
114 include: [ 143 include: [
115 { 144 {
116 model: () => AccountModel, 145 model: AccountModel,
117 required: true 146 required: true
118 } 147 }
119 ] 148 ]
120 }, 149 },
121 [ScopeNames.WITH_VIDEOS]: { 150 [ScopeNames.WITH_VIDEOS]: {
122 include: [ 151 include: [
123 () => VideoModel 152 VideoModel
124 ] 153 ]
125 }, 154 },
126 [ScopeNames.WITH_ACTOR]: { 155 [ScopeNames.WITH_ACTOR]: {
127 include: [ 156 include: [
128 () => ActorModel 157 ActorModel
129 ] 158 ]
130 } 159 }
131}) 160}))
132@Table({ 161@Table({
133 tableName: 'videoChannel', 162 tableName: 'videoChannel',
134 indexes 163 indexes
@@ -142,13 +171,13 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
142 171
143 @AllowNull(true) 172 @AllowNull(true)
144 @Default(null) 173 @Default(null)
145 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description')) 174 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
146 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max)) 175 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
147 description: string 176 description: string
148 177
149 @AllowNull(true) 178 @AllowNull(true)
150 @Default(null) 179 @Default(null)
151 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support')) 180 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
152 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max)) 181 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
153 support: string 182 support: string
154 183
@@ -192,6 +221,15 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
192 }) 221 })
193 Videos: VideoModel[] 222 Videos: VideoModel[]
194 223
224 @HasMany(() => VideoPlaylistModel, {
225 foreignKey: {
226 allowNull: true
227 },
228 onDelete: 'CASCADE',
229 hooks: true
230 })
231 VideoPlaylists: VideoPlaylistModel[]
232
195 @BeforeDestroy 233 @BeforeDestroy
196 static async sendDeleteIfOwned (instance: VideoChannelModel, options) { 234 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
197 if (!instance.Actor) { 235 if (!instance.Actor) {
@@ -274,7 +312,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
274 limit: options.count, 312 limit: options.count,
275 order: getSort(options.sort), 313 order: getSort(options.sort),
276 where: { 314 where: {
277 [Sequelize.Op.or]: [ 315 [Op.or]: [
278 Sequelize.literal( 316 Sequelize.literal(
279 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))' 317 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
280 ), 318 ),
@@ -320,7 +358,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
320 static loadByIdAndPopulateAccount (id: number) { 358 static loadByIdAndPopulateAccount (id: number) {
321 return VideoChannelModel.unscoped() 359 return VideoChannelModel.unscoped()
322 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) 360 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
323 .findById(id) 361 .findByPk(id)
324 } 362 }
325 363
326 static loadByIdAndAccount (id: number, accountId: number) { 364 static loadByIdAndAccount (id: number, accountId: number) {
@@ -339,7 +377,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
339 static loadAndPopulateAccount (id: number) { 377 static loadAndPopulateAccount (id: number) {
340 return VideoChannelModel.unscoped() 378 return VideoChannelModel.unscoped()
341 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) 379 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
342 .findById(id) 380 .findByPk(id)
343 } 381 }
344 382
345 static loadByUUIDAndPopulateAccount (uuid: string) { 383 static loadByUUIDAndPopulateAccount (uuid: string) {
@@ -378,6 +416,14 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
378 .findOne(query) 416 .findOne(query)
379 } 417 }
380 418
419 static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
420 const [ name, host ] = nameWithHost.split('@')
421
422 if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
423
424 return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
425 }
426
381 static loadLocalByNameAndPopulateAccount (name: string) { 427 static loadLocalByNameAndPopulateAccount (name: string) {
382 const query = { 428 const query = {
383 include: [ 429 include: [
@@ -431,7 +477,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
431 477
432 return VideoChannelModel.unscoped() 478 return VideoChannelModel.unscoped()
433 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ]) 479 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
434 .findById(id, options) 480 .findByPk(id, options)
435 } 481 }
436 482
437 toFormattedJSON (): VideoChannel { 483 toFormattedJSON (): VideoChannel {
@@ -452,6 +498,20 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
452 return Object.assign(actor, videoChannel) 498 return Object.assign(actor, videoChannel)
453 } 499 }
454 500
501 toFormattedSummaryJSON (): VideoChannelSummary {
502 const actor = this.Actor.toFormattedJSON()
503
504 return {
505 id: this.id,
506 uuid: actor.uuid,
507 name: actor.name,
508 displayName: this.getDisplayName(),
509 url: actor.url,
510 host: actor.host,
511 avatar: actor.avatar
512 }
513 }
514
455 toActivityPubObject (): ActivityPubActor { 515 toActivityPubObject (): ActivityPubActor {
456 const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel') 516 const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
457 517
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index 1163f9a0e..fee11ec5f 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,4 +1,3 @@
1import * as Sequelize from 'sequelize'
2import { 1import {
3 AllowNull, 2 AllowNull,
4 BeforeDestroy, 3 BeforeDestroy,
@@ -7,7 +6,6 @@ import {
7 CreatedAt, 6 CreatedAt,
8 DataType, 7 DataType,
9 ForeignKey, 8 ForeignKey,
10 IFindOptions,
11 Is, 9 Is,
12 Model, 10 Model,
13 Scopes, 11 Scopes,
@@ -18,7 +16,7 @@ import { ActivityTagObject } from '../../../shared/models/activitypub/objects/co
18import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 16import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
19import { VideoComment } from '../../../shared/models/videos/video-comment.model' 17import { VideoComment } from '../../../shared/models/videos/video-comment.model'
20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 18import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
21import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' 19import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
22import { sendDeleteVideoComment } from '../../lib/activitypub/send' 20import { sendDeleteVideoComment } from '../../lib/activitypub/send'
23import { AccountModel } from '../account/account' 21import { AccountModel } from '../account/account'
24import { ActorModel } from '../activitypub/actor' 22import { ActorModel } from '../activitypub/actor'
@@ -32,6 +30,7 @@ import { UserModel } from '../account/user'
32import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' 30import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
33import { regexpCapture } from '../../helpers/regexp' 31import { regexpCapture } from '../../helpers/regexp'
34import { uniq } from 'lodash' 32import { uniq } from 'lodash'
33import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
35 34
36enum ScopeNames { 35enum ScopeNames {
37 WITH_ACCOUNT = 'WITH_ACCOUNT', 36 WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -40,7 +39,7 @@ enum ScopeNames {
40 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API' 39 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
41} 40}
42 41
43@Scopes({ 42@Scopes(() => ({
44 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => { 43 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
45 return { 44 return {
46 attributes: { 45 attributes: {
@@ -64,22 +63,22 @@ enum ScopeNames {
64 ] 63 ]
65 ] 64 ]
66 } 65 }
67 } 66 } as FindOptions
68 }, 67 },
69 [ScopeNames.WITH_ACCOUNT]: { 68 [ScopeNames.WITH_ACCOUNT]: {
70 include: [ 69 include: [
71 { 70 {
72 model: () => AccountModel, 71 model: AccountModel,
73 include: [ 72 include: [
74 { 73 {
75 model: () => ActorModel, 74 model: ActorModel,
76 include: [ 75 include: [
77 { 76 {
78 model: () => ServerModel, 77 model: ServerModel,
79 required: false 78 required: false
80 }, 79 },
81 { 80 {
82 model: () => AvatarModel, 81 model: AvatarModel,
83 required: false 82 required: false
84 } 83 }
85 ] 84 ]
@@ -91,7 +90,7 @@ enum ScopeNames {
91 [ScopeNames.WITH_IN_REPLY_TO]: { 90 [ScopeNames.WITH_IN_REPLY_TO]: {
92 include: [ 91 include: [
93 { 92 {
94 model: () => VideoCommentModel, 93 model: VideoCommentModel,
95 as: 'InReplyToVideoComment' 94 as: 'InReplyToVideoComment'
96 } 95 }
97 ] 96 ]
@@ -99,19 +98,19 @@ enum ScopeNames {
99 [ScopeNames.WITH_VIDEO]: { 98 [ScopeNames.WITH_VIDEO]: {
100 include: [ 99 include: [
101 { 100 {
102 model: () => VideoModel, 101 model: VideoModel,
103 required: true, 102 required: true,
104 include: [ 103 include: [
105 { 104 {
106 model: () => VideoChannelModel.unscoped(), 105 model: VideoChannelModel.unscoped(),
107 required: true, 106 required: true,
108 include: [ 107 include: [
109 { 108 {
110 model: () => AccountModel, 109 model: AccountModel,
111 required: true, 110 required: true,
112 include: [ 111 include: [
113 { 112 {
114 model: () => ActorModel, 113 model: ActorModel,
115 required: true 114 required: true
116 } 115 }
117 ] 116 ]
@@ -122,7 +121,7 @@ enum ScopeNames {
122 } 121 }
123 ] 122 ]
124 } 123 }
125}) 124}))
126@Table({ 125@Table({
127 tableName: 'videoComment', 126 tableName: 'videoComment',
128 indexes: [ 127 indexes: [
@@ -244,8 +243,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
244 } 243 }
245 } 244 }
246 245
247 static loadById (id: number, t?: Sequelize.Transaction) { 246 static loadById (id: number, t?: Transaction) {
248 const query: IFindOptions<VideoCommentModel> = { 247 const query: FindOptions = {
249 where: { 248 where: {
250 id 249 id
251 } 250 }
@@ -256,8 +255,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
256 return VideoCommentModel.findOne(query) 255 return VideoCommentModel.findOne(query)
257 } 256 }
258 257
259 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) { 258 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction) {
260 const query: IFindOptions<VideoCommentModel> = { 259 const query: FindOptions = {
261 where: { 260 where: {
262 id 261 id
263 } 262 }
@@ -270,8 +269,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
270 .findOne(query) 269 .findOne(query)
271 } 270 }
272 271
273 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) { 272 static loadByUrlAndPopulateAccount (url: string, t?: Transaction) {
274 const query: IFindOptions<VideoCommentModel> = { 273 const query: FindOptions = {
275 where: { 274 where: {
276 url 275 url
277 } 276 }
@@ -282,8 +281,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
282 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query) 281 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
283 } 282 }
284 283
285 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) { 284 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Transaction) {
286 const query: IFindOptions<VideoCommentModel> = { 285 const query: FindOptions = {
287 where: { 286 where: {
288 url 287 url
289 } 288 }
@@ -307,15 +306,14 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
307 videoId, 306 videoId,
308 inReplyToCommentId: null, 307 inReplyToCommentId: null,
309 accountId: { 308 accountId: {
310 [Sequelize.Op.notIn]: Sequelize.literal( 309 [Op.notIn]: Sequelize.literal(
311 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' 310 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
312 ) 311 )
313 } 312 }
314 } 313 }
315 } 314 }
316 315
317 // FIXME: typings 316 const scopes: (string | ScopeOptions)[] = [
318 const scopes: any[] = [
319 ScopeNames.WITH_ACCOUNT, 317 ScopeNames.WITH_ACCOUNT,
320 { 318 {
321 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ] 319 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
@@ -336,15 +334,15 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
336 const userAccountId = user ? user.Account.id : undefined 334 const userAccountId = user ? user.Account.id : undefined
337 335
338 const query = { 336 const query = {
339 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ], 337 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
340 where: { 338 where: {
341 videoId, 339 videoId,
342 [ Sequelize.Op.or ]: [ 340 [ Op.or ]: [
343 { id: threadId }, 341 { id: threadId },
344 { originCommentId: threadId } 342 { originCommentId: threadId }
345 ], 343 ],
346 accountId: { 344 accountId: {
347 [Sequelize.Op.notIn]: Sequelize.literal( 345 [Op.notIn]: Sequelize.literal(
348 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' 346 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
349 ) 347 )
350 } 348 }
@@ -366,12 +364,12 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
366 }) 364 })
367 } 365 }
368 366
369 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') { 367 static listThreadParentComments (comment: VideoCommentModel, t: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
370 const query = { 368 const query = {
371 order: [ [ 'createdAt', order ] ], 369 order: [ [ 'createdAt', order ] ] as Order,
372 where: { 370 where: {
373 id: { 371 id: {
374 [ Sequelize.Op.in ]: Sequelize.literal('(' + 372 [ Op.in ]: Sequelize.literal('(' +
375 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + 373 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
376 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` + 374 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
377 'UNION ' + 375 'UNION ' +
@@ -380,7 +378,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
380 ') ' + 378 ') ' +
381 'SELECT id FROM children' + 379 'SELECT id FROM children' +
382 ')'), 380 ')'),
383 [ Sequelize.Op.ne ]: comment.id 381 [ Op.ne ]: comment.id
384 } 382 }
385 }, 383 },
386 transaction: t 384 transaction: t
@@ -391,9 +389,9 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
391 .findAll(query) 389 .findAll(query)
392 } 390 }
393 391
394 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') { 392 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
395 const query = { 393 const query = {
396 order: [ [ 'createdAt', order ] ], 394 order: [ [ 'createdAt', order ] ] as Order,
397 offset: start, 395 offset: start,
398 limit: count, 396 limit: count,
399 where: { 397 where: {
@@ -407,7 +405,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
407 405
408 static listForFeed (start: number, count: number, videoId?: number) { 406 static listForFeed (start: number, count: number, videoId?: number) {
409 const query = { 407 const query = {
410 order: [ [ 'createdAt', 'DESC' ] ], 408 order: [ [ 'createdAt', 'DESC' ] ] as Order,
411 offset: start, 409 offset: start,
412 limit: count, 410 limit: count,
413 where: {}, 411 where: {},
@@ -453,6 +451,19 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
453 } 451 }
454 } 452 }
455 453
454 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
455 const query = {
456 where: {
457 updatedAt: {
458 [Op.lt]: beforeUpdatedAt
459 },
460 videoId
461 }
462 }
463
464 return VideoCommentModel.destroy(query)
465 }
466
456 getCommentStaticPath () { 467 getCommentStaticPath () {
457 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId() 468 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
458 } 469 }
@@ -469,7 +480,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
469 let result: string[] = [] 480 let result: string[] = []
470 481
471 const localMention = `@(${actorNameAlphabet}+)` 482 const localMention = `@(${actorNameAlphabet}+)`
472 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}` 483 const remoteMention = `${localMention}@${WEBSERVER.HOST}`
473 484
474 const mentionRegex = this.isOwned() 485 const mentionRegex = this.isOwned()
475 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions? 486 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 1f1b76c1e..2203a7aba 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -19,10 +19,11 @@ import {
19 isVideoFileSizeValid, 19 isVideoFileSizeValid,
20 isVideoFPSResolutionValid 20 isVideoFPSResolutionValid
21} from '../../helpers/custom-validators/videos' 21} from '../../helpers/custom-validators/videos'
22import { throwIfNotValid } from '../utils' 22import { parseAggregateResult, throwIfNotValid } from '../utils'
23import { VideoModel } from './video' 23import { VideoModel } from './video'
24import * as Sequelize from 'sequelize'
25import { VideoRedundancyModel } from '../redundancy/video-redundancy' 24import { VideoRedundancyModel } from '../redundancy/video-redundancy'
25import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
26import { FindOptions, QueryTypes, Transaction } from 'sequelize'
26 27
27@Table({ 28@Table({
28 tableName: 'videoFile', 29 tableName: 'videoFile',
@@ -62,7 +63,7 @@ export class VideoFileModel extends Model<VideoFileModel> {
62 extname: string 63 extname: string
63 64
64 @AllowNull(false) 65 @AllowNull(false)
65 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) 66 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
66 @Column 67 @Column
67 infoHash: string 68 infoHash: string
68 69
@@ -86,25 +87,23 @@ export class VideoFileModel extends Model<VideoFileModel> {
86 87
87 @HasMany(() => VideoRedundancyModel, { 88 @HasMany(() => VideoRedundancyModel, {
88 foreignKey: { 89 foreignKey: {
89 allowNull: false 90 allowNull: true
90 }, 91 },
91 onDelete: 'CASCADE', 92 onDelete: 'CASCADE',
92 hooks: true 93 hooks: true
93 }) 94 })
94 RedundancyVideos: VideoRedundancyModel[] 95 RedundancyVideos: VideoRedundancyModel[]
95 96
96 static isInfohashExists (infoHash: string) { 97 static doesInfohashExist (infoHash: string) {
97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 98 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
98 const options = { 99 const options = {
99 type: Sequelize.QueryTypes.SELECT, 100 type: QueryTypes.SELECT,
100 bind: { infoHash }, 101 bind: { infoHash },
101 raw: true 102 raw: true
102 } 103 }
103 104
104 return VideoModel.sequelize.query(query, options) 105 return VideoModel.sequelize.query(query, options)
105 .then(results => { 106 .then(results => results.length === 1)
106 return results.length === 1
107 })
108 } 107 }
109 108
110 static loadWithVideo (id: number) { 109 static loadWithVideo (id: number) {
@@ -117,11 +116,34 @@ export class VideoFileModel extends Model<VideoFileModel> {
117 ] 116 ]
118 } 117 }
119 118
120 return VideoFileModel.findById(id, options) 119 return VideoFileModel.findByPk(id, options)
121 } 120 }
122 121
123 static async getStats () { 122 static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
124 let totalLocalVideoFilesSize = await VideoFileModel.sum('size', { 123 const query = {
124 include: [
125 {
126 model: VideoModel.unscoped(),
127 required: true,
128 include: [
129 {
130 model: VideoStreamingPlaylistModel.unscoped(),
131 required: true,
132 where: {
133 id: streamingPlaylistId
134 }
135 }
136 ]
137 }
138 ],
139 transaction
140 }
141
142 return VideoFileModel.findAll(query)
143 }
144
145 static getStats () {
146 const query: FindOptions = {
125 include: [ 147 include: [
126 { 148 {
127 attributes: [], 149 attributes: [],
@@ -131,13 +153,12 @@ export class VideoFileModel extends Model<VideoFileModel> {
131 } 153 }
132 } 154 }
133 ] 155 ]
134 } as any)
135 // Sequelize could return null...
136 if (!totalLocalVideoFilesSize) totalLocalVideoFilesSize = 0
137
138 return {
139 totalLocalVideoFilesSize
140 } 156 }
157
158 return VideoFileModel.aggregate('size', 'SUM', query)
159 .then(result => ({
160 totalLocalVideoFilesSize: parseAggregateResult(result)
161 }))
141 } 162 }
142 163
143 hasSameUniqueKeysThan (other: VideoFileModel) { 164 hasSameUniqueKeysThan (other: VideoFileModel) {
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index de0747f22..b947eb16f 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, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers' 5 ActivityPlaylistInfohashesObject,
6 ActivityPlaylistSegmentHashesObject,
7 ActivityUrlObject,
8 VideoTorrentObject
9} from '../../../shared/models/activitypub/objects'
10import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
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
@@ -19,12 +26,10 @@ export type VideoFormattingJSONOptions = {
19 waitTranscoding?: boolean, 26 waitTranscoding?: boolean,
20 scheduledUpdate?: boolean, 27 scheduledUpdate?: boolean,
21 blacklistInfo?: boolean 28 blacklistInfo?: boolean
29 playlistInfo?: boolean
22 } 30 }
23} 31}
24function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { 32function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
25 const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
26 const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
27
28 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined 33 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
29 34
30 const videoObject: Video = { 35 const videoObject: Video = {
@@ -54,30 +59,16 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
54 views: video.views, 59 views: video.views,
55 likes: video.likes, 60 likes: video.likes,
56 dislikes: video.dislikes, 61 dislikes: video.dislikes,
57 thumbnailPath: video.getThumbnailStaticPath(), 62 thumbnailPath: video.getMiniatureStaticPath(),
58 previewPath: video.getPreviewStaticPath(), 63 previewPath: video.getPreviewStaticPath(),
59 embedPath: video.getEmbedStaticPath(), 64 embedPath: video.getEmbedStaticPath(),
60 createdAt: video.createdAt, 65 createdAt: video.createdAt,
61 updatedAt: video.updatedAt, 66 updatedAt: video.updatedAt,
62 publishedAt: video.publishedAt, 67 publishedAt: video.publishedAt,
63 account: { 68 originallyPublishedAt: video.originallyPublishedAt,
64 id: formattedAccount.id, 69
65 uuid: formattedAccount.uuid, 70 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
66 name: formattedAccount.name, 71 channel: video.VideoChannel.toFormattedSummaryJSON(),
67 displayName: formattedAccount.displayName,
68 url: formattedAccount.url,
69 host: formattedAccount.host,
70 avatar: formattedAccount.avatar
71 },
72 channel: {
73 id: formattedVideoChannel.id,
74 uuid: formattedVideoChannel.uuid,
75 name: formattedVideoChannel.name,
76 displayName: formattedVideoChannel.displayName,
77 url: formattedVideoChannel.url,
78 host: formattedVideoChannel.host,
79 avatar: formattedVideoChannel.avatar
80 },
81 72
82 userHistory: userHistory ? { 73 userHistory: userHistory ? {
83 currentTime: userHistory.currentTime 74 currentTime: userHistory.currentTime
@@ -107,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
107 videoObject.blacklisted = !!video.VideoBlacklist 98 videoObject.blacklisted = !!video.VideoBlacklist
108 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null 99 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
109 } 100 }
101
102 if (options.additionalAttributes.playlistInfo === true) {
103 // We filtered on a specific videoId/videoPlaylistId, that is unique
104 const playlistElement = video.VideoPlaylistElements[0]
105
106 videoObject.playlistElement = {
107 position: playlistElement.position,
108 startTimestamp: playlistElement.startTimestamp,
109 stopTimestamp: playlistElement.stopTimestamp
110 }
111 }
110 } 112 }
111 113
112 return videoObject 114 return videoObject
@@ -120,7 +122,12 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
120 } 122 }
121 }) 123 })
122 124
125 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
126
123 const tags = video.Tags ? video.Tags.map(t => t.name) : [] 127 const tags = video.Tags ? video.Tags.map(t => t.name) : []
128
129 const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
130
124 const detailsJson = { 131 const detailsJson = {
125 support: video.support, 132 support: video.support,
126 descriptionPath: video.getDescriptionAPIPath(), 133 descriptionPath: video.getDescriptionAPIPath(),
@@ -128,12 +135,17 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
128 account: video.VideoChannel.Account.toFormattedJSON(), 135 account: video.VideoChannel.Account.toFormattedJSON(),
129 tags, 136 tags,
130 commentsEnabled: video.commentsEnabled, 137 commentsEnabled: video.commentsEnabled,
138 downloadEnabled: video.downloadEnabled,
131 waitTranscoding: video.waitTranscoding, 139 waitTranscoding: video.waitTranscoding,
132 state: { 140 state: {
133 id: video.state, 141 id: video.state,
134 label: VideoModel.getStateLabel(video.state) 142 label: VideoModel.getStateLabel(video.state)
135 }, 143 },
136 files: [] 144
145 trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
146
147 files: [],
148 streamingPlaylists
137 } 149 }
138 150
139 // Format and sort video files 151 // Format and sort video files
@@ -142,6 +154,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
142 return Object.assign(formattedJson, detailsJson) 154 return Object.assign(formattedJson, detailsJson)
143} 155}
144 156
157function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
158 if (isArray(playlists) === false) return []
159
160 return playlists
161 .map(playlist => {
162 const redundancies = isArray(playlist.RedundancyVideos)
163 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
164 : []
165
166 return {
167 id: playlist.id,
168 type: playlist.type,
169 playlistUrl: playlist.playlistUrl,
170 segmentsSha256Url: playlist.segmentsSha256Url,
171 redundancies
172 } as VideoStreamingPlaylist
173 })
174}
175
145function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { 176function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
146 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 177 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
147 178
@@ -232,12 +263,34 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
232 }) 263 })
233 } 264 }
234 265
266 for (const playlist of (video.VideoStreamingPlaylists || [])) {
267 let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
268
269 tag = playlist.p2pMediaLoaderInfohashes
270 .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
271 tag.push({
272 type: 'Link',
273 name: 'sha256',
274 mimeType: 'application/json' as 'application/json',
275 mediaType: 'application/json' as 'application/json',
276 href: playlist.segmentsSha256Url
277 })
278
279 url.push({
280 type: 'Link',
281 mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
282 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
283 href: playlist.playlistUrl,
284 tag
285 })
286 }
287
235 // Add video url too 288 // Add video url too
236 url.push({ 289 url.push({
237 type: 'Link', 290 type: 'Link',
238 mimeType: 'text/html', 291 mimeType: 'text/html',
239 mediaType: 'text/html', 292 mediaType: 'text/html',
240 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 293 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
241 }) 294 })
242 295
243 const subtitleLanguage = [] 296 const subtitleLanguage = []
@@ -248,6 +301,8 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
248 }) 301 })
249 } 302 }
250 303
304 const miniature = video.getMiniature()
305
251 return { 306 return {
252 type: 'Video' as 'Video', 307 type: 'Video' as 'Video',
253 id: video.url, 308 id: video.url,
@@ -263,7 +318,9 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
263 waitTranscoding: video.waitTranscoding, 318 waitTranscoding: video.waitTranscoding,
264 state: video.state, 319 state: video.state,
265 commentsEnabled: video.commentsEnabled, 320 commentsEnabled: video.commentsEnabled,
321 downloadEnabled: video.downloadEnabled,
266 published: video.publishedAt.toISOString(), 322 published: video.publishedAt.toISOString(),
323 originallyPublishedAt: video.originallyPublishedAt ? video.originallyPublishedAt.toISOString() : null,
267 updated: video.updatedAt.toISOString(), 324 updated: video.updatedAt.toISOString(),
268 mediaType: 'text/markdown', 325 mediaType: 'text/markdown',
269 content: video.getTruncatedDescription(), 326 content: video.getTruncatedDescription(),
@@ -271,10 +328,10 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
271 subtitleLanguage, 328 subtitleLanguage,
272 icon: { 329 icon: {
273 type: 'Image', 330 type: 'Image',
274 url: video.getThumbnailUrl(baseUrlHttp), 331 url: miniature.getFileUrl(),
275 mediaType: 'image/jpeg', 332 mediaType: 'image/jpeg',
276 width: THUMBNAILS_SIZE.width, 333 width: miniature.width,
277 height: THUMBNAILS_SIZE.height 334 height: miniature.height
278 }, 335 },
279 url, 336 url,
280 likes: getVideoLikesActivityPubUrl(video), 337 likes: getVideoLikesActivityPubUrl(video),
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index c723e57c0..480a671c8 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -13,7 +13,7 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers' 16import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
17import { getSort, throwIfNotValid } from '../utils' 17import { getSort, throwIfNotValid } from '../utils'
18import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 18import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
19import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports' 19import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
@@ -21,18 +21,18 @@ import { VideoImport, VideoImportState } from '../../../shared'
21import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' 21import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
22import { UserModel } from '../account/user' 22import { UserModel } from '../account/user'
23 23
24@DefaultScope({ 24@DefaultScope(() => ({
25 include: [ 25 include: [
26 { 26 {
27 model: () => UserModel.unscoped(), 27 model: UserModel.unscoped(),
28 required: true 28 required: true
29 }, 29 },
30 { 30 {
31 model: () => VideoModel.scope([ VideoModelScopeNames.WITH_ACCOUNT_DETAILS, VideoModelScopeNames.WITH_TAGS]), 31 model: VideoModel.scope([ VideoModelScopeNames.WITH_ACCOUNT_DETAILS, VideoModelScopeNames.WITH_TAGS]),
32 required: false 32 required: false
33 } 33 }
34 ] 34 ]
35}) 35}))
36 36
37@Table({ 37@Table({
38 tableName: 'videoImport', 38 tableName: 'videoImport',
@@ -55,13 +55,13 @@ export class VideoImportModel extends Model<VideoImportModel> {
55 55
56 @AllowNull(true) 56 @AllowNull(true)
57 @Default(null) 57 @Default(null)
58 @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl')) 58 @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl', true))
59 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) 59 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max))
60 targetUrl: string 60 targetUrl: string
61 61
62 @AllowNull(true) 62 @AllowNull(true)
63 @Default(null) 63 @Default(null)
64 @Is('VideoImportMagnetUri', value => throwIfNotValid(value, isVideoMagnetUriValid, 'magnetUri')) 64 @Is('VideoImportMagnetUri', value => throwIfNotValid(value, isVideoMagnetUriValid, 'magnetUri', true))
65 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) // Use the same constraints than URLs 65 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) // Use the same constraints than URLs
66 magnetUri: string 66 magnetUri: string
67 67
@@ -115,7 +115,7 @@ export class VideoImportModel extends Model<VideoImportModel> {
115 } 115 }
116 116
117 static loadAndPopulateVideo (id: number) { 117 static loadAndPopulateVideo (id: number) {
118 return VideoImportModel.findById(id) 118 return VideoImportModel.findByPk(id)
119 } 119 }
120 120
121 static listUserVideoImportsForApi (userId: number, start: number, count: number, sort: string) { 121 static listUserVideoImportsForApi (userId: number, start: number, count: number, sort: string) {
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
new file mode 100644
index 000000000..eeb3d6bbd
--- /dev/null
+++ b/server/models/video/video-playlist-element.ts
@@ -0,0 +1,230 @@
1import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 DataType,
7 Default,
8 ForeignKey,
9 Is,
10 IsInt,
11 Min,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { VideoModel } from './video'
17import { VideoPlaylistModel } from './video-playlist'
18import { getSort, throwIfNotValid } from '../utils'
19import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
20import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
21import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
22import * as validator from 'validator'
23import { AggregateOptions, Op, Sequelize, Transaction } from 'sequelize'
24
25@Table({
26 tableName: 'videoPlaylistElement',
27 indexes: [
28 {
29 fields: [ 'videoPlaylistId' ]
30 },
31 {
32 fields: [ 'videoId' ]
33 },
34 {
35 fields: [ 'videoPlaylistId', 'videoId' ],
36 unique: true
37 },
38 {
39 fields: [ 'url' ],
40 unique: true
41 }
42 ]
43})
44export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> {
45 @CreatedAt
46 createdAt: Date
47
48 @UpdatedAt
49 updatedAt: Date
50
51 @AllowNull(false)
52 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
53 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
54 url: string
55
56 @AllowNull(false)
57 @Default(1)
58 @IsInt
59 @Min(1)
60 @Column
61 position: number
62
63 @AllowNull(true)
64 @IsInt
65 @Min(0)
66 @Column
67 startTimestamp: number
68
69 @AllowNull(true)
70 @IsInt
71 @Min(0)
72 @Column
73 stopTimestamp: number
74
75 @ForeignKey(() => VideoPlaylistModel)
76 @Column
77 videoPlaylistId: number
78
79 @BelongsTo(() => VideoPlaylistModel, {
80 foreignKey: {
81 allowNull: false
82 },
83 onDelete: 'CASCADE'
84 })
85 VideoPlaylist: VideoPlaylistModel
86
87 @ForeignKey(() => VideoModel)
88 @Column
89 videoId: number
90
91 @BelongsTo(() => VideoModel, {
92 foreignKey: {
93 allowNull: false
94 },
95 onDelete: 'CASCADE'
96 })
97 Video: VideoModel
98
99 static deleteAllOf (videoPlaylistId: number, transaction?: Transaction) {
100 const query = {
101 where: {
102 videoPlaylistId
103 },
104 transaction
105 }
106
107 return VideoPlaylistElementModel.destroy(query)
108 }
109
110 static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) {
111 const query = {
112 where: {
113 videoPlaylistId,
114 videoId
115 }
116 }
117
118 return VideoPlaylistElementModel.findOne(query)
119 }
120
121 static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) {
122 const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId }
123 const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId }
124
125 const query = {
126 include: [
127 {
128 attributes: [ 'privacy' ],
129 model: VideoPlaylistModel.unscoped(),
130 where: playlistWhere
131 },
132 {
133 attributes: [ 'url' ],
134 model: VideoModel.unscoped(),
135 where: videoWhere
136 }
137 ]
138 }
139
140 return VideoPlaylistElementModel.findOne(query)
141 }
142
143 static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number, t?: Transaction) {
144 const query = {
145 attributes: [ 'url' ],
146 offset: start,
147 limit: count,
148 order: getSort('position'),
149 where: {
150 videoPlaylistId
151 },
152 transaction: t
153 }
154
155 return VideoPlaylistElementModel
156 .findAndCountAll(query)
157 .then(({ rows, count }) => {
158 return { total: count, data: rows.map(e => e.url) }
159 })
160 }
161
162 static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
163 const query: AggregateOptions<number> = {
164 where: {
165 videoPlaylistId
166 },
167 transaction
168 }
169
170 return VideoPlaylistElementModel.max('position', query)
171 .then(position => position ? position + 1 : 1)
172 }
173
174 static reassignPositionOf (
175 videoPlaylistId: number,
176 firstPosition: number,
177 endPosition: number,
178 newPosition: number,
179 transaction?: Transaction
180 ) {
181 const query = {
182 where: {
183 videoPlaylistId,
184 position: {
185 [Op.gte]: firstPosition,
186 [Op.lte]: endPosition
187 }
188 },
189 transaction,
190 validate: false // We use a literal to update the position
191 }
192
193 return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query)
194 }
195
196 static increasePositionOf (
197 videoPlaylistId: number,
198 fromPosition: number,
199 toPosition?: number,
200 by = 1,
201 transaction?: Transaction
202 ) {
203 const query = {
204 where: {
205 videoPlaylistId,
206 position: {
207 [Op.gte]: fromPosition
208 }
209 },
210 transaction
211 }
212
213 return VideoPlaylistElementModel.increment({ position: by }, query)
214 }
215
216 toActivityPubObject (): PlaylistElementObject {
217 const base: PlaylistElementObject = {
218 id: this.url,
219 type: 'PlaylistElement',
220
221 url: this.Video.url,
222 position: this.position
223 }
224
225 if (this.startTimestamp) base.startTimestamp = this.startTimestamp
226 if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp
227
228 return base
229 }
230}
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
new file mode 100644
index 000000000..63b4a0715
--- /dev/null
+++ b/server/models/video/video-playlist.ts
@@ -0,0 +1,531 @@
1import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 DataType,
7 Default,
8 ForeignKey,
9 HasMany,
10 HasOne,
11 Is,
12 IsUUID,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
19import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils'
20import {
21 isVideoPlaylistDescriptionValid,
22 isVideoPlaylistNameValid,
23 isVideoPlaylistPrivacyValid
24} from '../../helpers/custom-validators/video-playlists'
25import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
26import {
27 ACTIVITY_PUB,
28 CONSTRAINTS_FIELDS,
29 STATIC_PATHS,
30 THUMBNAILS_SIZE,
31 VIDEO_PLAYLIST_PRIVACIES,
32 VIDEO_PLAYLIST_TYPES,
33 WEBSERVER
34} from '../../initializers/constants'
35import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
36import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
37import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
38import { join } from 'path'
39import { VideoPlaylistElementModel } from './video-playlist-element'
40import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
41import { activityPubCollectionPagination } from '../../helpers/activitypub'
42import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
43import { ThumbnailModel } from './thumbnail'
44import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
45import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
46
47enum ScopeNames {
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
49 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
50 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
51 WITH_ACCOUNT = 'WITH_ACCOUNT',
52 WITH_THUMBNAIL = 'WITH_THUMBNAIL',
53 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
54}
55
56type AvailableForListOptions = {
57 followerActorId: number
58 type?: VideoPlaylistType
59 accountId?: number
60 videoChannelId?: number
61 privateAndUnlisted?: boolean
62}
63
64@Scopes(() => ({
65 [ ScopeNames.WITH_THUMBNAIL ]: {
66 include: [
67 {
68 model: ThumbnailModel,
69 required: false
70 }
71 ]
72 },
73 [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
74 attributes: {
75 include: [
76 [
77 literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
78 'videosLength'
79 ]
80 ]
81 }
82 } as FindOptions,
83 [ ScopeNames.WITH_ACCOUNT ]: {
84 include: [
85 {
86 model: AccountModel,
87 required: true
88 }
89 ]
90 },
91 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: {
92 include: [
93 {
94 model: AccountModel.scope(AccountScopeNames.SUMMARY),
95 required: true
96 },
97 {
98 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
99 required: false
100 }
101 ]
102 },
103 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: {
104 include: [
105 {
106 model: AccountModel,
107 required: true
108 },
109 {
110 model: VideoChannelModel,
111 required: false
112 }
113 ]
114 },
115 [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => {
116 // Only list local playlists OR playlists that are on an instance followed by actorId
117 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
118 const actorWhere = {
119 [ Op.or ]: [
120 {
121 serverId: null
122 },
123 {
124 serverId: {
125 [ Op.in ]: literal(inQueryInstanceFollow)
126 }
127 }
128 ]
129 }
130
131 const whereAnd: WhereOptions[] = []
132
133 if (options.privateAndUnlisted !== true) {
134 whereAnd.push({
135 privacy: VideoPlaylistPrivacy.PUBLIC
136 })
137 }
138
139 if (options.accountId) {
140 whereAnd.push({
141 ownerAccountId: options.accountId
142 })
143 }
144
145 if (options.videoChannelId) {
146 whereAnd.push({
147 videoChannelId: options.videoChannelId
148 })
149 }
150
151 if (options.type) {
152 whereAnd.push({
153 type: options.type
154 })
155 }
156
157 const where = {
158 [Op.and]: whereAnd
159 }
160
161 const accountScope = {
162 method: [ AccountScopeNames.SUMMARY, actorWhere ]
163 }
164
165 return {
166 where,
167 include: [
168 {
169 model: AccountModel.scope(accountScope),
170 required: true
171 },
172 {
173 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
174 required: false
175 }
176 ]
177 } as FindOptions
178 }
179}))
180
181@Table({
182 tableName: 'videoPlaylist',
183 indexes: [
184 {
185 fields: [ 'ownerAccountId' ]
186 },
187 {
188 fields: [ 'videoChannelId' ]
189 },
190 {
191 fields: [ 'url' ],
192 unique: true
193 }
194 ]
195})
196export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
197 @CreatedAt
198 createdAt: Date
199
200 @UpdatedAt
201 updatedAt: Date
202
203 @AllowNull(false)
204 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
205 @Column
206 name: string
207
208 @AllowNull(true)
209 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
210 @Column
211 description: string
212
213 @AllowNull(false)
214 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
215 @Column
216 privacy: VideoPlaylistPrivacy
217
218 @AllowNull(false)
219 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
220 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
221 url: string
222
223 @AllowNull(false)
224 @Default(DataType.UUIDV4)
225 @IsUUID(4)
226 @Column(DataType.UUID)
227 uuid: string
228
229 @AllowNull(false)
230 @Default(VideoPlaylistType.REGULAR)
231 @Column
232 type: VideoPlaylistType
233
234 @ForeignKey(() => AccountModel)
235 @Column
236 ownerAccountId: number
237
238 @BelongsTo(() => AccountModel, {
239 foreignKey: {
240 allowNull: false
241 },
242 onDelete: 'CASCADE'
243 })
244 OwnerAccount: AccountModel
245
246 @ForeignKey(() => VideoChannelModel)
247 @Column
248 videoChannelId: number
249
250 @BelongsTo(() => VideoChannelModel, {
251 foreignKey: {
252 allowNull: true
253 },
254 onDelete: 'CASCADE'
255 })
256 VideoChannel: VideoChannelModel
257
258 @HasMany(() => VideoPlaylistElementModel, {
259 foreignKey: {
260 name: 'videoPlaylistId',
261 allowNull: false
262 },
263 onDelete: 'CASCADE'
264 })
265 VideoPlaylistElements: VideoPlaylistElementModel[]
266
267 @HasOne(() => ThumbnailModel, {
268
269 foreignKey: {
270 name: 'videoPlaylistId',
271 allowNull: true
272 },
273 onDelete: 'CASCADE',
274 hooks: true
275 })
276 Thumbnail: ThumbnailModel
277
278 static listForApi (options: {
279 followerActorId: number
280 start: number,
281 count: number,
282 sort: string,
283 type?: VideoPlaylistType,
284 accountId?: number,
285 videoChannelId?: number,
286 privateAndUnlisted?: boolean
287 }) {
288 const query = {
289 offset: options.start,
290 limit: options.count,
291 order: getSort(options.sort)
292 }
293
294 const scopes: (string | ScopeOptions)[] = [
295 {
296 method: [
297 ScopeNames.AVAILABLE_FOR_LIST,
298 {
299 type: options.type,
300 followerActorId: options.followerActorId,
301 accountId: options.accountId,
302 videoChannelId: options.videoChannelId,
303 privateAndUnlisted: options.privateAndUnlisted
304 } as AvailableForListOptions
305 ]
306 },
307 ScopeNames.WITH_VIDEOS_LENGTH,
308 ScopeNames.WITH_THUMBNAIL
309 ]
310
311 return VideoPlaylistModel
312 .scope(scopes)
313 .findAndCountAll(query)
314 .then(({ rows, count }) => {
315 return { total: count, data: rows }
316 })
317 }
318
319 static listPublicUrlsOfForAP (accountId: number, start: number, count: number) {
320 const query = {
321 attributes: [ 'url' ],
322 offset: start,
323 limit: count,
324 where: {
325 ownerAccountId: accountId,
326 privacy: VideoPlaylistPrivacy.PUBLIC
327 }
328 }
329
330 return VideoPlaylistModel.findAndCountAll(query)
331 .then(({ rows, count }) => {
332 return { total: count, data: rows.map(p => p.url) }
333 })
334 }
335
336 static listPlaylistIdsOf (accountId: number, videoIds: number[]) {
337 const query = {
338 attributes: [ 'id' ],
339 where: {
340 ownerAccountId: accountId
341 },
342 include: [
343 {
344 attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ],
345 model: VideoPlaylistElementModel.unscoped(),
346 where: {
347 videoId: {
348 [Op.in]: videoIds // FIXME: sequelize ANY seems broken
349 }
350 },
351 required: true
352 }
353 ]
354 }
355
356 return VideoPlaylistModel.findAll(query)
357 }
358
359 static doesPlaylistExist (url: string) {
360 const query = {
361 attributes: [],
362 where: {
363 url
364 }
365 }
366
367 return VideoPlaylistModel
368 .findOne(query)
369 .then(e => !!e)
370 }
371
372 static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction) {
373 const where = buildWhereIdOrUUID(id)
374
375 const query = {
376 where,
377 transaction
378 }
379
380 return VideoPlaylistModel
381 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
382 .findOne(query)
383 }
384
385 static loadWithAccountAndChannel (id: number | string, transaction: Transaction) {
386 const where = buildWhereIdOrUUID(id)
387
388 const query = {
389 where,
390 transaction
391 }
392
393 return VideoPlaylistModel
394 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
395 .findOne(query)
396 }
397
398 static loadByUrlAndPopulateAccount (url: string) {
399 const query = {
400 where: {
401 url
402 }
403 }
404
405 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
406 }
407
408 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
409 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
410 }
411
412 static getTypeLabel (type: VideoPlaylistType) {
413 return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
414 }
415
416 static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
417 const query = {
418 where: {
419 videoChannelId
420 },
421 transaction
422 }
423
424 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
425 }
426
427 async setAndSaveThumbnail (thumbnail: ThumbnailModel, t: Transaction) {
428 thumbnail.videoPlaylistId = this.id
429
430 this.Thumbnail = await thumbnail.save({ transaction: t })
431 }
432
433 hasThumbnail () {
434 return !!this.Thumbnail
435 }
436
437 generateThumbnailName () {
438 const extension = '.jpg'
439
440 return 'playlist-' + this.uuid + extension
441 }
442
443 getThumbnailUrl () {
444 if (!this.hasThumbnail()) return null
445
446 return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
447 }
448
449 getThumbnailStaticPath () {
450 if (!this.hasThumbnail()) return null
451
452 return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
453 }
454
455 setAsRefreshed () {
456 this.changed('updatedAt', true)
457
458 return this.save()
459 }
460
461 isOwned () {
462 return this.OwnerAccount.isOwned()
463 }
464
465 isOutdated () {
466 if (this.isOwned()) return false
467
468 return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
469 }
470
471 toFormattedJSON (): VideoPlaylist {
472 return {
473 id: this.id,
474 uuid: this.uuid,
475 isLocal: this.isOwned(),
476
477 displayName: this.name,
478 description: this.description,
479 privacy: {
480 id: this.privacy,
481 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
482 },
483
484 thumbnailPath: this.getThumbnailStaticPath(),
485
486 type: {
487 id: this.type,
488 label: VideoPlaylistModel.getTypeLabel(this.type)
489 },
490
491 videosLength: this.get('videosLength') as number,
492
493 createdAt: this.createdAt,
494 updatedAt: this.updatedAt,
495
496 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
497 videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null
498 }
499 }
500
501 toActivityPubObject (page: number, t: Transaction): Promise<PlaylistObject> {
502 const handler = (start: number, count: number) => {
503 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
504 }
505
506 let icon: ActivityIconObject
507 if (this.hasThumbnail()) {
508 icon = {
509 type: 'Image' as 'Image',
510 url: this.getThumbnailUrl(),
511 mediaType: 'image/jpeg' as 'image/jpeg',
512 width: THUMBNAILS_SIZE.width,
513 height: THUMBNAILS_SIZE.height
514 }
515 }
516
517 return activityPubCollectionPagination(this.url, handler, page)
518 .then(o => {
519 return Object.assign(o, {
520 type: 'Playlist' as 'Playlist',
521 name: this.name,
522 content: this.description,
523 uuid: this.uuid,
524 published: this.createdAt.toISOString(),
525 updated: this.updatedAt.toISOString(),
526 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
527 icon
528 })
529 })
530 }
531}
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index c87f71277..fda2d7cea 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -2,7 +2,7 @@ import * as Sequelize from 'sequelize'
2import * as Bluebird from 'bluebird' 2import * as Bluebird from 'bluebird'
3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
4import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 4import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
5import { CONSTRAINTS_FIELDS } from '../../initializers' 5import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
6import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
7import { ActorModel } from '../activitypub/actor' 7import { ActorModel } from '../activitypub/actor'
8import { throwIfNotValid } from '../utils' 8import { throwIfNotValid } from '../utils'
@@ -14,15 +14,15 @@ enum ScopeNames {
14 WITH_ACTOR = 'WITH_ACTOR' 14 WITH_ACTOR = 'WITH_ACTOR'
15} 15}
16 16
17@Scopes({ 17@Scopes(() => ({
18 [ScopeNames.FULL]: { 18 [ScopeNames.FULL]: {
19 include: [ 19 include: [
20 { 20 {
21 model: () => ActorModel, 21 model: ActorModel,
22 required: true 22 required: true
23 }, 23 },
24 { 24 {
25 model: () => VideoModel, 25 model: VideoModel,
26 required: true 26 required: true
27 } 27 }
28 ] 28 ]
@@ -30,12 +30,12 @@ enum ScopeNames {
30 [ScopeNames.WITH_ACTOR]: { 30 [ScopeNames.WITH_ACTOR]: {
31 include: [ 31 include: [
32 { 32 {
33 model: () => ActorModel, 33 model: ActorModel,
34 required: true 34 required: true
35 } 35 }
36 ] 36 ]
37 } 37 }
38}) 38}))
39@Table({ 39@Table({
40 tableName: 'videoShare', 40 tableName: 'videoShare',
41 indexes: [ 41 indexes: [
@@ -125,7 +125,7 @@ export class VideoShareModel extends Model<VideoShareModel> {
125 .then(res => res.map(r => r.Actor)) 125 .then(res => res.map(r => r.Actor))
126 } 126 }
127 127
128 static loadActorsByVideoOwner (actorOwnerId: number, t: Sequelize.Transaction): Bluebird<ActorModel[]> { 128 static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Sequelize.Transaction): Bluebird<ActorModel[]> {
129 const query = { 129 const query = {
130 attributes: [], 130 attributes: [],
131 include: [ 131 include: [
@@ -200,4 +200,17 @@ export class VideoShareModel extends Model<VideoShareModel> {
200 200
201 return VideoShareModel.findAndCountAll(query) 201 return VideoShareModel.findAndCountAll(query)
202 } 202 }
203
204 static cleanOldSharesOf (videoId: number, beforeUpdatedAt: Date) {
205 const query = {
206 where: {
207 updatedAt: {
208 [Sequelize.Op.lt]: beforeUpdatedAt
209 },
210 videoId
211 }
212 }
213
214 return VideoShareModel.destroy(query)
215 }
203} 216}
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
new file mode 100644
index 000000000..31dc82c54
--- /dev/null
+++ b/server/models/video/video-streaming-playlist.ts
@@ -0,0 +1,172 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, HasMany, Is, Model, Table, UpdatedAt, DataType } from 'sequelize-typescript'
2import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
3import { throwIfNotValid } from '../utils'
4import { VideoModel } from './video'
5import { VideoRedundancyModel } from '../redundancy/video-redundancy'
6import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
7import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
8import { CONSTRAINTS_FIELDS, STATIC_PATHS, P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
9import { VideoFileModel } from './video-file'
10import { join } from 'path'
11import { sha1 } from '../../helpers/core-utils'
12import { isArrayOf } from '../../helpers/custom-validators/misc'
13import { QueryTypes, Op } from 'sequelize'
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 @Column
54 p2pMediaLoaderPeerVersion: number
55
56 @AllowNull(false)
57 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url'))
58 @Column
59 segmentsSha256Url: string
60
61 @ForeignKey(() => VideoModel)
62 @Column
63 videoId: number
64
65 @BelongsTo(() => VideoModel, {
66 foreignKey: {
67 allowNull: false
68 },
69 onDelete: 'CASCADE'
70 })
71 Video: VideoModel
72
73 @HasMany(() => VideoRedundancyModel, {
74 foreignKey: {
75 allowNull: false
76 },
77 onDelete: 'CASCADE',
78 hooks: true
79 })
80 RedundancyVideos: VideoRedundancyModel[]
81
82 static doesInfohashExist (infoHash: string) {
83 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
84 const options = {
85 type: QueryTypes.SELECT as QueryTypes.SELECT,
86 bind: { infoHash },
87 raw: true
88 }
89
90 return VideoModel.sequelize.query<object>(query, options)
91 .then(results => results.length === 1)
92 }
93
94 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, videoFiles: VideoFileModel[]) {
95 const hashes: string[] = []
96
97 // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L115
98 for (let i = 0; i < videoFiles.length; i++) {
99 hashes.push(sha1(`${P2P_MEDIA_LOADER_PEER_VERSION}${playlistUrl}+V${i}`))
100 }
101
102 return hashes
103 }
104
105 static listByIncorrectPeerVersion () {
106 const query = {
107 where: {
108 p2pMediaLoaderPeerVersion: {
109 [Op.ne]: P2P_MEDIA_LOADER_PEER_VERSION
110 }
111 }
112 }
113
114 return VideoStreamingPlaylistModel.findAll(query)
115 }
116
117 static loadWithVideo (id: number) {
118 const options = {
119 include: [
120 {
121 model: VideoModel.unscoped(),
122 required: true
123 }
124 ]
125 }
126
127 return VideoStreamingPlaylistModel.findByPk(id, options)
128 }
129
130 static getHlsPlaylistFilename (resolution: number) {
131 return resolution + '.m3u8'
132 }
133
134 static getMasterHlsPlaylistFilename () {
135 return 'master.m3u8'
136 }
137
138 static getHlsSha256SegmentsFilename () {
139 return 'segments-sha256.json'
140 }
141
142 static getHlsVideoName (uuid: string, resolution: number) {
143 return `${uuid}-${resolution}-fragmented.mp4`
144 }
145
146 static getHlsMasterPlaylistStaticPath (videoUUID: string) {
147 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
148 }
149
150 static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) {
151 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
152 }
153
154 static getHlsSha256SegmentsStaticPath (videoUUID: string) {
155 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
156 }
157
158 getStringType () {
159 if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
160
161 return 'unknown'
162 }
163
164 getVideoRedundancyUrl (baseUrlHttp: string) {
165 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getStringType() + '/' + this.Video.uuid
166 }
167
168 hasSameUniqueKeysThan (other: VideoStreamingPlaylistModel) {
169 return this.type === other.type &&
170 this.videoId === other.videoId
171 }
172}
diff --git a/server/models/video/video-views.ts b/server/models/video/video-views.ts
index fde5f7056..40db5effd 100644
--- a/server/models/video/video-views.ts
+++ b/server/models/video/video-views.ts
@@ -4,6 +4,7 @@ import * as Sequelize from 'sequelize'
4 4
5@Table({ 5@Table({
6 tableName: 'videoView', 6 tableName: 'videoView',
7 updatedAt: false,
7 indexes: [ 8 indexes: [
8 { 9 {
9 fields: [ 'videoId' ] 10 fields: [ 'videoId' ]
@@ -41,4 +42,18 @@ export class VideoViewModel extends Model<VideoViewModel> {
41 }) 42 })
42 Video: VideoModel 43 Video: VideoModel
43 44
45 static removeOldRemoteViewsHistory (beforeDate: string) {
46 const query = {
47 where: {
48 startDate: {
49 [Sequelize.Op.lt]: beforeDate
50 },
51 videoId: {
52 [Sequelize.Op.in]: Sequelize.literal('(SELECT "id" FROM "video" WHERE "remote" IS TRUE)')
53 }
54 }
55 }
56
57 return VideoViewModel.destroy(query)
58 }
44} 59}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 80a6c7832..c0a7892a4 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -3,7 +3,18 @@ import { maxBy } from 'lodash'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent' 4import * as parseTorrent from 'parse-torrent'
5import { join } from 'path' 5import { join } from 'path'
6import * as Sequelize from 'sequelize' 6import {
7 CountOptions,
8 FindOptions,
9 IncludeOptions,
10 ModelIndexesOptions,
11 Op,
12 QueryTypes,
13 ScopeOptions,
14 Sequelize,
15 Transaction,
16 WhereOptions
17} from 'sequelize'
7import { 18import {
8 AllowNull, 19 AllowNull,
9 BeforeDestroy, 20 BeforeDestroy,
@@ -16,8 +27,6 @@ import {
16 ForeignKey, 27 ForeignKey,
17 HasMany, 28 HasMany,
18 HasOne, 29 HasOne,
19 IFindOptions,
20 IIncludeOptions,
21 Is, 30 Is,
22 IsInt, 31 IsInt,
23 IsUUID, 32 IsUUID,
@@ -45,35 +54,43 @@ import {
45 isVideoStateValid, 54 isVideoStateValid,
46 isVideoSupportValid 55 isVideoSupportValid
47} from '../../helpers/custom-validators/videos' 56} from '../../helpers/custom-validators/videos'
48import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils' 57import { getVideoFileResolution } from '../../helpers/ffmpeg-utils'
49import { logger } from '../../helpers/logger' 58import { logger } from '../../helpers/logger'
50import { getServerActor } from '../../helpers/utils' 59import { getServerActor } from '../../helpers/utils'
51import { 60import {
52 ACTIVITY_PUB, 61 ACTIVITY_PUB,
53 API_VERSION, 62 API_VERSION,
54 CONFIG,
55 CONSTRAINTS_FIELDS, 63 CONSTRAINTS_FIELDS,
56 PREVIEWS_SIZE, 64 HLS_REDUNDANCY_DIRECTORY,
65 HLS_STREAMING_PLAYLIST_DIRECTORY,
57 REMOTE_SCHEME, 66 REMOTE_SCHEME,
58 STATIC_DOWNLOAD_PATHS, 67 STATIC_DOWNLOAD_PATHS,
59 STATIC_PATHS, 68 STATIC_PATHS,
60 THUMBNAILS_SIZE,
61 VIDEO_CATEGORIES, 69 VIDEO_CATEGORIES,
62 VIDEO_LANGUAGES, 70 VIDEO_LANGUAGES,
63 VIDEO_LICENCES, 71 VIDEO_LICENCES,
64 VIDEO_PRIVACIES, 72 VIDEO_PRIVACIES,
65 VIDEO_STATES 73 VIDEO_STATES,
66} from '../../initializers' 74 WEBSERVER
75} from '../../initializers/constants'
67import { sendDeleteVideo } from '../../lib/activitypub/send' 76import { sendDeleteVideo } from '../../lib/activitypub/send'
68import { AccountModel } from '../account/account' 77import { AccountModel } from '../account/account'
69import { AccountVideoRateModel } from '../account/account-video-rate' 78import { AccountVideoRateModel } from '../account/account-video-rate'
70import { ActorModel } from '../activitypub/actor' 79import { ActorModel } from '../activitypub/actor'
71import { AvatarModel } from '../avatar/avatar' 80import { AvatarModel } from '../avatar/avatar'
72import { ServerModel } from '../server/server' 81import { ServerModel } from '../server/server'
73import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' 82import {
83 buildBlockedAccountSQL,
84 buildTrigramSearchIndex,
85 buildWhereIdOrUUID,
86 createSimilarityAttribute,
87 getVideoSort,
88 isOutdated,
89 throwIfNotValid
90} from '../utils'
74import { TagModel } from './tag' 91import { TagModel } from './tag'
75import { VideoAbuseModel } from './video-abuse' 92import { VideoAbuseModel } from './video-abuse'
76import { VideoChannelModel } from './video-channel' 93import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
77import { VideoCommentModel } from './video-comment' 94import { VideoCommentModel } from './video-comment'
78import { VideoFileModel } from './video-file' 95import { VideoFileModel } from './video-file'
79import { VideoShareModel } from './video-share' 96import { VideoShareModel } from './video-share'
@@ -91,13 +108,17 @@ import {
91 videoModelToFormattedDetailsJSON, 108 videoModelToFormattedDetailsJSON,
92 videoModelToFormattedJSON 109 videoModelToFormattedJSON
93} from './video-format-utils' 110} from './video-format-utils'
94import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 111import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user' 112import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import' 113import { VideoImportModel } from './video-import'
114import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
115import { VideoPlaylistElementModel } from './video-playlist-element'
116import { CONFIG } from '../../initializers/config'
117import { ThumbnailModel } from './thumbnail'
118import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
98 119
99// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 120// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
100const indexes: Sequelize.DefineIndexesOptions[] = [ 121const indexes: (ModelIndexesOptions & { where?: WhereOptions })[] = [
101 buildTrigramSearchIndex('video_name_trigram', 'name'), 122 buildTrigramSearchIndex('video_name_trigram', 'name'),
102 123
103 { fields: [ 'createdAt' ] }, 124 { fields: [ 'createdAt' ] },
@@ -106,10 +127,18 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
106 { fields: [ 'views' ] }, 127 { fields: [ 'views' ] },
107 { fields: [ 'channelId' ] }, 128 { fields: [ 'channelId' ] },
108 { 129 {
130 fields: [ 'originallyPublishedAt' ],
131 where: {
132 originallyPublishedAt: {
133 [Op.ne]: null
134 }
135 }
136 },
137 {
109 fields: [ 'category' ], // We don't care videos with an unknown category 138 fields: [ 'category' ], // We don't care videos with an unknown category
110 where: { 139 where: {
111 category: { 140 category: {
112 [Sequelize.Op.ne]: null 141 [Op.ne]: null
113 } 142 }
114 } 143 }
115 }, 144 },
@@ -117,7 +146,7 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
117 fields: [ 'licence' ], // We don't care videos with an unknown licence 146 fields: [ 'licence' ], // We don't care videos with an unknown licence
118 where: { 147 where: {
119 licence: { 148 licence: {
120 [Sequelize.Op.ne]: null 149 [Op.ne]: null
121 } 150 }
122 } 151 }
123 }, 152 },
@@ -125,7 +154,7 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
125 fields: [ 'language' ], // We don't care videos with an unknown language 154 fields: [ 'language' ], // We don't care videos with an unknown language
126 where: { 155 where: {
127 language: { 156 language: {
128 [Sequelize.Op.ne]: null 157 [Op.ne]: null
129 } 158 }
130 } 159 }
131 }, 160 },
@@ -159,11 +188,17 @@ export enum ScopeNames {
159 WITH_FILES = 'WITH_FILES', 188 WITH_FILES = 'WITH_FILES',
160 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 189 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
161 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 190 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
162 WITH_USER_HISTORY = 'WITH_USER_HISTORY' 191 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
192 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
193 WITH_USER_ID = 'WITH_USER_ID',
194 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
163} 195}
164 196
165type ForAPIOptions = { 197type ForAPIOptions = {
166 ids: number[] 198 ids: number[]
199
200 videoPlaylistId?: number
201
167 withFiles?: boolean 202 withFiles?: boolean
168} 203}
169 204
@@ -171,6 +206,9 @@ type AvailableForListIDsOptions = {
171 serverAccountId: number 206 serverAccountId: number
172 followerActorId: number 207 followerActorId: number
173 includeLocalVideos: boolean 208 includeLocalVideos: boolean
209
210 withoutId?: boolean
211
174 filter?: VideoFilter 212 filter?: VideoFilter
175 categoryOneOf?: number[] 213 categoryOneOf?: number[]
176 nsfw?: boolean 214 nsfw?: boolean
@@ -178,72 +216,38 @@ type AvailableForListIDsOptions = {
178 languageOneOf?: string[] 216 languageOneOf?: string[]
179 tagsOneOf?: string[] 217 tagsOneOf?: string[]
180 tagsAllOf?: string[] 218 tagsAllOf?: string[]
219
181 withFiles?: boolean 220 withFiles?: boolean
221
182 accountId?: number 222 accountId?: number
183 videoChannelId?: number 223 videoChannelId?: number
224
225 videoPlaylistId?: number
226
184 trendingDays?: number 227 trendingDays?: number
185 user?: UserModel, 228 user?: UserModel,
186 historyOfUser?: UserModel 229 historyOfUser?: UserModel
187} 230}
188 231
189@Scopes({ 232@Scopes(() => ({
190 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { 233 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
191 const accountInclude = { 234 const query: FindOptions = {
192 attributes: [ 'id', 'name' ], 235 where: {
193 model: AccountModel.unscoped(), 236 id: {
194 required: true, 237 [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken
195 include: [
196 {
197 attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
198 model: ActorModel.unscoped(),
199 required: true,
200 include: [
201 {
202 attributes: [ 'host' ],
203 model: ServerModel.unscoped(),
204 required: false
205 },
206 {
207 model: AvatarModel.unscoped(),
208 required: false
209 }
210 ]
211 } 238 }
212 ] 239 },
213 }
214
215 const videoChannelInclude = {
216 attributes: [ 'name', 'description', 'id' ],
217 model: VideoChannelModel.unscoped(),
218 required: true,
219 include: [ 240 include: [
220 { 241 {
221 attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], 242 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }),
222 model: ActorModel.unscoped(), 243 required: true
223 required: true,
224 include: [
225 {
226 attributes: [ 'host' ],
227 model: ServerModel.unscoped(),
228 required: false
229 },
230 {
231 model: AvatarModel.unscoped(),
232 required: false
233 }
234 ]
235 }, 244 },
236 accountInclude 245 {
237 ] 246 attributes: [ 'type', 'filename' ],
238 } 247 model: ThumbnailModel,
239 248 required: false
240 const query: IFindOptions<VideoModel> = {
241 where: {
242 id: {
243 [ Sequelize.Op.any ]: options.ids
244 } 249 }
245 }, 250 ]
246 include: [ videoChannelInclude ]
247 } 251 }
248 252
249 if (options.withFiles === true) { 253 if (options.withFiles === true) {
@@ -253,24 +257,36 @@ type AvailableForListIDsOptions = {
253 }) 257 })
254 } 258 }
255 259
260 if (options.videoPlaylistId) {
261 query.include.push({
262 model: VideoPlaylistElementModel.unscoped(),
263 required: true,
264 where: {
265 videoPlaylistId: options.videoPlaylistId
266 }
267 })
268 }
269
256 return query 270 return query
257 }, 271 },
258 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { 272 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
259 const query: IFindOptions<VideoModel> = { 273 const attributes = options.withoutId === true ? [] : [ 'id' ]
274
275 const query: FindOptions = {
260 raw: true, 276 raw: true,
261 attributes: [ 'id' ], 277 attributes,
262 where: { 278 where: {
263 id: { 279 id: {
264 [ Sequelize.Op.and ]: [ 280 [ Op.and ]: [
265 { 281 {
266 [ Sequelize.Op.notIn ]: Sequelize.literal( 282 [ Op.notIn ]: Sequelize.literal(
267 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")' 283 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
268 ) 284 )
269 } 285 }
270 ] 286 ]
271 }, 287 },
272 channelId: { 288 channelId: {
273 [ Sequelize.Op.notIn ]: Sequelize.literal( 289 [ Op.notIn ]: Sequelize.literal(
274 '(' + 290 '(' +
275 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' + 291 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
276 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) + 292 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
@@ -288,12 +304,12 @@ type AvailableForListIDsOptions = {
288 // Always list public videos 304 // Always list public videos
289 privacy: VideoPrivacy.PUBLIC, 305 privacy: VideoPrivacy.PUBLIC,
290 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding 306 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
291 [ Sequelize.Op.or ]: [ 307 [ Op.or ]: [
292 { 308 {
293 state: VideoState.PUBLISHED 309 state: VideoState.PUBLISHED
294 }, 310 },
295 { 311 {
296 [ Sequelize.Op.and ]: { 312 [ Op.and ]: {
297 state: VideoState.TO_TRANSCODE, 313 state: VideoState.TO_TRANSCODE,
298 waitTranscoding: false 314 waitTranscoding: false
299 } 315 }
@@ -304,8 +320,21 @@ type AvailableForListIDsOptions = {
304 Object.assign(query.where, privacyWhere) 320 Object.assign(query.where, privacyWhere)
305 } 321 }
306 322
323 if (options.videoPlaylistId) {
324 query.include.push({
325 attributes: [],
326 model: VideoPlaylistElementModel.unscoped(),
327 required: true,
328 where: {
329 videoPlaylistId: options.videoPlaylistId
330 }
331 })
332
333 query.subQuery = false
334 }
335
307 if (options.filter || options.accountId || options.videoChannelId) { 336 if (options.filter || options.accountId || options.videoChannelId) {
308 const videoChannelInclude: IIncludeOptions = { 337 const videoChannelInclude: IncludeOptions = {
309 attributes: [], 338 attributes: [],
310 model: VideoChannelModel.unscoped(), 339 model: VideoChannelModel.unscoped(),
311 required: true 340 required: true
@@ -318,7 +347,7 @@ type AvailableForListIDsOptions = {
318 } 347 }
319 348
320 if (options.filter || options.accountId) { 349 if (options.filter || options.accountId) {
321 const accountInclude: IIncludeOptions = { 350 const accountInclude: IncludeOptions = {
322 attributes: [], 351 attributes: [],
323 model: AccountModel.unscoped(), 352 model: AccountModel.unscoped(),
324 required: true 353 required: true
@@ -358,8 +387,8 @@ type AvailableForListIDsOptions = {
358 387
359 // Force actorId to be a number to avoid SQL injections 388 // Force actorId to be a number to avoid SQL injections
360 const actorIdNumber = parseInt(options.followerActorId.toString(), 10) 389 const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
361 query.where[ 'id' ][ Sequelize.Op.and ].push({ 390 query.where[ 'id' ][ Op.and ].push({
362 [ Sequelize.Op.in ]: Sequelize.literal( 391 [ Op.in ]: Sequelize.literal(
363 '(' + 392 '(' +
364 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + 393 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
365 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + 394 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
@@ -378,8 +407,8 @@ type AvailableForListIDsOptions = {
378 } 407 }
379 408
380 if (options.withFiles === true) { 409 if (options.withFiles === true) {
381 query.where[ 'id' ][ Sequelize.Op.and ].push({ 410 query.where[ 'id' ][ Op.and ].push({
382 [ Sequelize.Op.in ]: Sequelize.literal( 411 [ Op.in ]: Sequelize.literal(
383 '(SELECT "videoId" FROM "videoFile")' 412 '(SELECT "videoId" FROM "videoFile")'
384 ) 413 )
385 }) 414 })
@@ -393,8 +422,8 @@ type AvailableForListIDsOptions = {
393 } 422 }
394 423
395 if (options.tagsOneOf) { 424 if (options.tagsOneOf) {
396 query.where[ 'id' ][ Sequelize.Op.and ].push({ 425 query.where[ 'id' ][ Op.and ].push({
397 [ Sequelize.Op.in ]: Sequelize.literal( 426 [ Op.in ]: Sequelize.literal(
398 '(' + 427 '(' +
399 'SELECT "videoId" FROM "videoTag" ' + 428 'SELECT "videoId" FROM "videoTag" ' +
400 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + 429 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
@@ -405,8 +434,8 @@ type AvailableForListIDsOptions = {
405 } 434 }
406 435
407 if (options.tagsAllOf) { 436 if (options.tagsAllOf) {
408 query.where[ 'id' ][ Sequelize.Op.and ].push({ 437 query.where[ 'id' ][ Op.and ].push({
409 [ Sequelize.Op.in ]: Sequelize.literal( 438 [ Op.in ]: Sequelize.literal(
410 '(' + 439 '(' +
411 'SELECT "videoId" FROM "videoTag" ' + 440 'SELECT "videoId" FROM "videoTag" ' +
412 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + 441 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
@@ -424,19 +453,19 @@ type AvailableForListIDsOptions = {
424 453
425 if (options.categoryOneOf) { 454 if (options.categoryOneOf) {
426 query.where[ 'category' ] = { 455 query.where[ 'category' ] = {
427 [ Sequelize.Op.or ]: options.categoryOneOf 456 [ Op.or ]: options.categoryOneOf
428 } 457 }
429 } 458 }
430 459
431 if (options.licenceOneOf) { 460 if (options.licenceOneOf) {
432 query.where[ 'licence' ] = { 461 query.where[ 'licence' ] = {
433 [ Sequelize.Op.or ]: options.licenceOneOf 462 [ Op.or ]: options.licenceOneOf
434 } 463 }
435 } 464 }
436 465
437 if (options.languageOneOf) { 466 if (options.languageOneOf) {
438 query.where[ 'language' ] = { 467 query.where[ 'language' ] = {
439 [ Sequelize.Op.or ]: options.languageOneOf 468 [ Op.or ]: options.languageOneOf
440 } 469 }
441 } 470 }
442 471
@@ -463,36 +492,60 @@ type AvailableForListIDsOptions = {
463 492
464 return query 493 return query
465 }, 494 },
495 [ ScopeNames.WITH_THUMBNAILS ]: {
496 include: [
497 {
498 model: ThumbnailModel,
499 required: false
500 }
501 ]
502 },
503 [ ScopeNames.WITH_USER_ID ]: {
504 include: [
505 {
506 attributes: [ 'accountId' ],
507 model: VideoChannelModel.unscoped(),
508 required: true,
509 include: [
510 {
511 attributes: [ 'userId' ],
512 model: AccountModel.unscoped(),
513 required: true
514 }
515 ]
516 }
517 ]
518 },
466 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { 519 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
467 include: [ 520 include: [
468 { 521 {
469 model: () => VideoChannelModel.unscoped(), 522 model: VideoChannelModel.unscoped(),
470 required: true, 523 required: true,
471 include: [ 524 include: [
472 { 525 {
473 attributes: { 526 attributes: {
474 exclude: [ 'privateKey', 'publicKey' ] 527 exclude: [ 'privateKey', 'publicKey' ]
475 }, 528 },
476 model: () => ActorModel.unscoped(), 529 model: ActorModel.unscoped(),
477 required: true, 530 required: true,
478 include: [ 531 include: [
479 { 532 {
480 attributes: [ 'host' ], 533 attributes: [ 'host' ],
481 model: () => ServerModel.unscoped(), 534 model: ServerModel.unscoped(),
482 required: false 535 required: false
483 }, 536 },
484 { 537 {
485 model: () => AvatarModel.unscoped(), 538 model: AvatarModel.unscoped(),
486 required: false 539 required: false
487 } 540 }
488 ] 541 ]
489 }, 542 },
490 { 543 {
491 model: () => AccountModel.unscoped(), 544 model: AccountModel.unscoped(),
492 required: true, 545 required: true,
493 include: [ 546 include: [
494 { 547 {
495 model: () => ActorModel.unscoped(), 548 model: ActorModel.unscoped(),
496 attributes: { 549 attributes: {
497 exclude: [ 'privateKey', 'publicKey' ] 550 exclude: [ 'privateKey', 'publicKey' ]
498 }, 551 },
@@ -500,11 +553,11 @@ type AvailableForListIDsOptions = {
500 include: [ 553 include: [
501 { 554 {
502 attributes: [ 'host' ], 555 attributes: [ 'host' ],
503 model: () => ServerModel.unscoped(), 556 model: ServerModel.unscoped(),
504 required: false 557 required: false
505 }, 558 },
506 { 559 {
507 model: () => AvatarModel.unscoped(), 560 model: AvatarModel.unscoped(),
508 required: false 561 required: false
509 } 562 }
510 ] 563 ]
@@ -516,38 +569,69 @@ type AvailableForListIDsOptions = {
516 ] 569 ]
517 }, 570 },
518 [ ScopeNames.WITH_TAGS ]: { 571 [ ScopeNames.WITH_TAGS ]: {
519 include: [ () => TagModel ] 572 include: [ TagModel ]
520 }, 573 },
521 [ ScopeNames.WITH_BLACKLISTED ]: { 574 [ ScopeNames.WITH_BLACKLISTED ]: {
522 include: [ 575 include: [
523 { 576 {
524 attributes: [ 'id', 'reason' ], 577 attributes: [ 'id', 'reason' ],
525 model: () => VideoBlacklistModel, 578 model: VideoBlacklistModel,
526 required: false 579 required: false
527 } 580 }
528 ] 581 ]
529 }, 582 },
530 [ ScopeNames.WITH_FILES ]: { 583 [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
531 include: [ 584 let subInclude: any[] = []
532 { 585
533 model: () => VideoFileModel.unscoped(), 586 if (withRedundancies === true) {
534 // FIXME: typings 587 subInclude = [
535 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join 588 {
536 required: false, 589 attributes: [ 'fileUrl' ],
537 include: [ 590 model: VideoRedundancyModel.unscoped(),
538 { 591 required: false
539 attributes: [ 'fileUrl' ], 592 }
540 model: () => VideoRedundancyModel.unscoped(), 593 ]
541 required: false 594 }
542 } 595
543 ] 596 return {
544 } 597 include: [
545 ] 598 {
599 model: VideoFileModel.unscoped(),
600 separate: true, // We may have multiple files, having multiple redundancies so let's separate this join
601 required: false,
602 include: subInclude
603 }
604 ]
605 }
606 },
607 [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
608 let subInclude: any[] = []
609
610 if (withRedundancies === true) {
611 subInclude = [
612 {
613 attributes: [ 'fileUrl' ],
614 model: VideoRedundancyModel.unscoped(),
615 required: false
616 }
617 ]
618 }
619
620 return {
621 include: [
622 {
623 model: VideoStreamingPlaylistModel.unscoped(),
624 separate: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join
625 required: false,
626 include: subInclude
627 }
628 ]
629 }
546 }, 630 },
547 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { 631 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
548 include: [ 632 include: [
549 { 633 {
550 model: () => ScheduleVideoUpdateModel.unscoped(), 634 model: ScheduleVideoUpdateModel.unscoped(),
551 required: false 635 required: false
552 } 636 }
553 ] 637 ]
@@ -566,7 +650,7 @@ type AvailableForListIDsOptions = {
566 ] 650 ]
567 } 651 }
568 } 652 }
569}) 653}))
570@Table({ 654@Table({
571 tableName: 'video', 655 tableName: 'video',
572 indexes 656 indexes
@@ -586,19 +670,19 @@ export class VideoModel extends Model<VideoModel> {
586 670
587 @AllowNull(true) 671 @AllowNull(true)
588 @Default(null) 672 @Default(null)
589 @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category')) 673 @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category', true))
590 @Column 674 @Column
591 category: number 675 category: number
592 676
593 @AllowNull(true) 677 @AllowNull(true)
594 @Default(null) 678 @Default(null)
595 @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence')) 679 @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence', true))
596 @Column 680 @Column
597 licence: number 681 licence: number
598 682
599 @AllowNull(true) 683 @AllowNull(true)
600 @Default(null) 684 @Default(null)
601 @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language')) 685 @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language', true))
602 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max)) 686 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
603 language: string 687 language: string
604 688
@@ -614,13 +698,13 @@ export class VideoModel extends Model<VideoModel> {
614 698
615 @AllowNull(true) 699 @AllowNull(true)
616 @Default(null) 700 @Default(null)
617 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description')) 701 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
618 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max)) 702 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
619 description: string 703 description: string
620 704
621 @AllowNull(true) 705 @AllowNull(true)
622 @Default(null) 706 @Default(null)
623 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support')) 707 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
624 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max)) 708 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
625 support: string 709 support: string
626 710
@@ -665,6 +749,10 @@ export class VideoModel extends Model<VideoModel> {
665 749
666 @AllowNull(false) 750 @AllowNull(false)
667 @Column 751 @Column
752 downloadEnabled: boolean
753
754 @AllowNull(false)
755 @Column
668 waitTranscoding: boolean 756 waitTranscoding: boolean
669 757
670 @AllowNull(false) 758 @AllowNull(false)
@@ -680,10 +768,15 @@ export class VideoModel extends Model<VideoModel> {
680 updatedAt: Date 768 updatedAt: Date
681 769
682 @AllowNull(false) 770 @AllowNull(false)
683 @Default(Sequelize.NOW) 771 @Default(DataType.NOW)
684 @Column 772 @Column
685 publishedAt: Date 773 publishedAt: Date
686 774
775 @AllowNull(true)
776 @Default(null)
777 @Column
778 originallyPublishedAt: Date
779
687 @ForeignKey(() => VideoChannelModel) 780 @ForeignKey(() => VideoChannelModel)
688 @Column 781 @Column
689 channelId: number 782 channelId: number
@@ -703,6 +796,25 @@ export class VideoModel extends Model<VideoModel> {
703 }) 796 })
704 Tags: TagModel[] 797 Tags: TagModel[]
705 798
799 @HasMany(() => ThumbnailModel, {
800 foreignKey: {
801 name: 'videoId',
802 allowNull: true
803 },
804 hooks: true,
805 onDelete: 'cascade'
806 })
807 Thumbnails: ThumbnailModel[]
808
809 @HasMany(() => VideoPlaylistElementModel, {
810 foreignKey: {
811 name: 'videoId',
812 allowNull: false
813 },
814 onDelete: 'cascade'
815 })
816 VideoPlaylistElements: VideoPlaylistElementModel[]
817
706 @HasMany(() => VideoAbuseModel, { 818 @HasMany(() => VideoAbuseModel, {
707 foreignKey: { 819 foreignKey: {
708 name: 'videoId', 820 name: 'videoId',
@@ -722,6 +834,16 @@ export class VideoModel extends Model<VideoModel> {
722 }) 834 })
723 VideoFiles: VideoFileModel[] 835 VideoFiles: VideoFileModel[]
724 836
837 @HasMany(() => VideoStreamingPlaylistModel, {
838 foreignKey: {
839 name: 'videoId',
840 allowNull: false
841 },
842 hooks: true,
843 onDelete: 'cascade'
844 })
845 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
846
725 @HasMany(() => VideoShareModel, { 847 @HasMany(() => VideoShareModel, {
726 foreignKey: { 848 foreignKey: {
727 name: 'videoId', 849 name: 'videoId',
@@ -833,20 +955,19 @@ export class VideoModel extends Model<VideoModel> {
833 955
834 logger.info('Removing files of video %s.', instance.url) 956 logger.info('Removing files of video %s.', instance.url)
835 957
836 tasks.push(instance.removeThumbnail())
837
838 if (instance.isOwned()) { 958 if (instance.isOwned()) {
839 if (!Array.isArray(instance.VideoFiles)) { 959 if (!Array.isArray(instance.VideoFiles)) {
840 instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[] 960 instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
841 } 961 }
842 962
843 tasks.push(instance.removePreview())
844
845 // Remove physical files and torrents 963 // Remove physical files and torrents
846 instance.VideoFiles.forEach(file => { 964 instance.VideoFiles.forEach(file => {
847 tasks.push(instance.removeFile(file)) 965 tasks.push(instance.removeFile(file))
848 tasks.push(instance.removeTorrent(file)) 966 tasks.push(instance.removeTorrent(file))
849 }) 967 })
968
969 // Remove playlists file
970 tasks.push(instance.removeStreamingPlaylist())
850 } 971 }
851 972
852 // Do not wait video deletion because we could be in a transaction 973 // Do not wait video deletion because we could be in a transaction
@@ -858,10 +979,6 @@ export class VideoModel extends Model<VideoModel> {
858 return undefined 979 return undefined
859 } 980 }
860 981
861 static list () {
862 return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
863 }
864
865 static listLocal () { 982 static listLocal () {
866 const query = { 983 const query = {
867 where: { 984 where: {
@@ -869,7 +986,11 @@ export class VideoModel extends Model<VideoModel> {
869 } 986 }
870 } 987 }
871 988
872 return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query) 989 return VideoModel.scope([
990 ScopeNames.WITH_FILES,
991 ScopeNames.WITH_STREAMING_PLAYLISTS,
992 ScopeNames.WITH_THUMBNAILS
993 ]).findAll(query)
873 } 994 }
874 995
875 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { 996 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -892,12 +1013,12 @@ export class VideoModel extends Model<VideoModel> {
892 distinct: true, 1013 distinct: true,
893 offset: start, 1014 offset: start,
894 limit: count, 1015 limit: count,
895 order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ]), 1016 order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
896 where: { 1017 where: {
897 id: { 1018 id: {
898 [ Sequelize.Op.in ]: Sequelize.literal('(' + rawQuery + ')') 1019 [ Op.in ]: Sequelize.literal('(' + rawQuery + ')')
899 }, 1020 },
900 [ Sequelize.Op.or ]: [ 1021 [ Op.or ]: [
901 { privacy: VideoPrivacy.PUBLIC }, 1022 { privacy: VideoPrivacy.PUBLIC },
902 { privacy: VideoPrivacy.UNLISTED } 1023 { privacy: VideoPrivacy.UNLISTED }
903 ] 1024 ]
@@ -914,10 +1035,10 @@ export class VideoModel extends Model<VideoModel> {
914 required: false, 1035 required: false,
915 // We only want videos shared by this actor 1036 // We only want videos shared by this actor
916 where: { 1037 where: {
917 [ Sequelize.Op.and ]: [ 1038 [ Op.and ]: [
918 { 1039 {
919 id: { 1040 id: {
920 [ Sequelize.Op.not ]: null 1041 [ Op.not ]: null
921 } 1042 }
922 }, 1043 },
923 { 1044 {
@@ -961,9 +1082,8 @@ export class VideoModel extends Model<VideoModel> {
961 } 1082 }
962 1083
963 return Bluebird.all([ 1084 return Bluebird.all([
964 // FIXME: typing issue 1085 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
965 VideoModel.findAll(query as any), 1086 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
966 VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
967 ]).then(([ rows, totals ]) => { 1087 ]).then(([ rows, totals ]) => {
968 // totals: totalVideos + totalVideoShares 1088 // totals: totalVideos + totalVideoShares
969 let totalVideos = 0 1089 let totalVideos = 0
@@ -980,43 +1100,49 @@ export class VideoModel extends Model<VideoModel> {
980 } 1100 }
981 1101
982 static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) { 1102 static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) {
983 const query: IFindOptions<VideoModel> = { 1103 function buildBaseQuery (): FindOptions {
984 offset: start, 1104 return {
985 limit: count, 1105 offset: start,
986 order: getVideoSort(sort), 1106 limit: count,
987 include: [ 1107 order: getVideoSort(sort),
988 { 1108 include: [
989 model: VideoChannelModel, 1109 {
990 required: true, 1110 model: VideoChannelModel,
991 include: [ 1111 required: true,
992 { 1112 include: [
993 model: AccountModel, 1113 {
994 where: { 1114 model: AccountModel,
995 id: accountId 1115 where: {
996 }, 1116 id: accountId
997 required: true 1117 },
998 } 1118 required: true
999 ] 1119 }
1000 }, 1120 ]
1001 { 1121 }
1002 model: ScheduleVideoUpdateModel, 1122 ]
1003 required: false 1123 }
1004 },
1005 {
1006 model: VideoBlacklistModel,
1007 required: false
1008 }
1009 ]
1010 } 1124 }
1011 1125
1126 const countQuery = buildBaseQuery()
1127 const findQuery = buildBaseQuery()
1128
1129 const findScopes = [
1130 ScopeNames.WITH_SCHEDULED_UPDATE,
1131 ScopeNames.WITH_BLACKLISTED,
1132 ScopeNames.WITH_THUMBNAILS
1133 ]
1134
1012 if (withFiles === true) { 1135 if (withFiles === true) {
1013 query.include.push({ 1136 findQuery.include.push({
1014 model: VideoFileModel.unscoped(), 1137 model: VideoFileModel.unscoped(),
1015 required: true 1138 required: true
1016 }) 1139 })
1017 } 1140 }
1018 1141
1019 return VideoModel.findAndCountAll(query).then(({ rows, count }) => { 1142 return Promise.all([
1143 VideoModel.count(countQuery),
1144 VideoModel.scope(findScopes).findAll(findQuery)
1145 ]).then(([ count, rows ]) => {
1020 return { 1146 return {
1021 data: rows, 1147 data: rows,
1022 total: count 1148 total: count
@@ -1040,6 +1166,7 @@ export class VideoModel extends Model<VideoModel> {
1040 accountId?: number, 1166 accountId?: number,
1041 videoChannelId?: number, 1167 videoChannelId?: number,
1042 followerActorId?: number 1168 followerActorId?: number
1169 videoPlaylistId?: number,
1043 trendingDays?: number, 1170 trendingDays?: number,
1044 user?: UserModel, 1171 user?: UserModel,
1045 historyOfUser?: UserModel 1172 historyOfUser?: UserModel
@@ -1048,7 +1175,7 @@ export class VideoModel extends Model<VideoModel> {
1048 throw new Error('Try to filter all-local but no user has not the see all videos right') 1175 throw new Error('Try to filter all-local but no user has not the see all videos right')
1049 } 1176 }
1050 1177
1051 const query: IFindOptions<VideoModel> = { 1178 const query: FindOptions = {
1052 offset: options.start, 1179 offset: options.start,
1053 limit: options.count, 1180 limit: options.count,
1054 order: getVideoSort(options.sort) 1181 order: getVideoSort(options.sort)
@@ -1079,6 +1206,7 @@ export class VideoModel extends Model<VideoModel> {
1079 withFiles: options.withFiles, 1206 withFiles: options.withFiles,
1080 accountId: options.accountId, 1207 accountId: options.accountId,
1081 videoChannelId: options.videoChannelId, 1208 videoChannelId: options.videoChannelId,
1209 videoPlaylistId: options.videoPlaylistId,
1082 includeLocalVideos: options.includeLocalVideos, 1210 includeLocalVideos: options.includeLocalVideos,
1083 user: options.user, 1211 user: options.user,
1084 historyOfUser: options.historyOfUser, 1212 historyOfUser: options.historyOfUser,
@@ -1096,6 +1224,8 @@ export class VideoModel extends Model<VideoModel> {
1096 sort?: string 1224 sort?: string
1097 startDate?: string // ISO 8601 1225 startDate?: string // ISO 8601
1098 endDate?: string // ISO 8601 1226 endDate?: string // ISO 8601
1227 originallyPublishedStartDate?: string
1228 originallyPublishedEndDate?: string
1099 nsfw?: boolean 1229 nsfw?: boolean
1100 categoryOneOf?: number[] 1230 categoryOneOf?: number[]
1101 licenceOneOf?: number[] 1231 licenceOneOf?: number[]
@@ -1112,17 +1242,26 @@ export class VideoModel extends Model<VideoModel> {
1112 if (options.startDate || options.endDate) { 1242 if (options.startDate || options.endDate) {
1113 const publishedAtRange = {} 1243 const publishedAtRange = {}
1114 1244
1115 if (options.startDate) publishedAtRange[ Sequelize.Op.gte ] = options.startDate 1245 if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate
1116 if (options.endDate) publishedAtRange[ Sequelize.Op.lte ] = options.endDate 1246 if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate
1117 1247
1118 whereAnd.push({ publishedAt: publishedAtRange }) 1248 whereAnd.push({ publishedAt: publishedAtRange })
1119 } 1249 }
1120 1250
1251 if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) {
1252 const originallyPublishedAtRange = {}
1253
1254 if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate
1255 if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate
1256
1257 whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange })
1258 }
1259
1121 if (options.durationMin || options.durationMax) { 1260 if (options.durationMin || options.durationMax) {
1122 const durationRange = {} 1261 const durationRange = {}
1123 1262
1124 if (options.durationMin) durationRange[ Sequelize.Op.gte ] = options.durationMin 1263 if (options.durationMin) durationRange[ Op.gte ] = options.durationMin
1125 if (options.durationMax) durationRange[ Sequelize.Op.lte ] = options.durationMax 1264 if (options.durationMax) durationRange[ Op.lte ] = options.durationMax
1126 1265
1127 whereAnd.push({ duration: durationRange }) 1266 whereAnd.push({ duration: durationRange })
1128 } 1267 }
@@ -1134,7 +1273,7 @@ export class VideoModel extends Model<VideoModel> {
1134 whereAnd.push( 1273 whereAnd.push(
1135 { 1274 {
1136 id: { 1275 id: {
1137 [ Sequelize.Op.in ]: Sequelize.literal( 1276 [ Op.in ]: Sequelize.literal(
1138 '(' + 1277 '(' +
1139 'SELECT "video"."id" FROM "video" ' + 1278 'SELECT "video"."id" FROM "video" ' +
1140 'WHERE ' + 1279 'WHERE ' +
@@ -1160,7 +1299,7 @@ export class VideoModel extends Model<VideoModel> {
1160 ) 1299 )
1161 } 1300 }
1162 1301
1163 const query: IFindOptions<VideoModel> = { 1302 const query: FindOptions = {
1164 attributes: { 1303 attributes: {
1165 include: attributesInclude 1304 include: attributesInclude
1166 }, 1305 },
@@ -1168,7 +1307,7 @@ export class VideoModel extends Model<VideoModel> {
1168 limit: options.count, 1307 limit: options.count,
1169 order: getVideoSort(options.sort), 1308 order: getVideoSort(options.sort),
1170 where: { 1309 where: {
1171 [ Sequelize.Op.and ]: whereAnd 1310 [ Op.and ]: whereAnd
1172 } 1311 }
1173 } 1312 }
1174 1313
@@ -1190,18 +1329,32 @@ export class VideoModel extends Model<VideoModel> {
1190 return VideoModel.getAvailableForApi(query, queryOptions) 1329 return VideoModel.getAvailableForApi(query, queryOptions)
1191 } 1330 }
1192 1331
1193 static load (id: number | string, t?: Sequelize.Transaction) { 1332 static load (id: number | string, t?: Transaction) {
1194 const where = VideoModel.buildWhereIdOrUUID(id) 1333 const where = buildWhereIdOrUUID(id)
1195 const options = { 1334 const options = {
1196 where, 1335 where,
1197 transaction: t 1336 transaction: t
1198 } 1337 }
1199 1338
1200 return VideoModel.findOne(options) 1339 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1201 } 1340 }
1202 1341
1203 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1342 static loadWithRights (id: number | string, t?: Transaction) {
1204 const where = VideoModel.buildWhereIdOrUUID(id) 1343 const where = buildWhereIdOrUUID(id)
1344 const options = {
1345 where,
1346 transaction: t
1347 }
1348
1349 return VideoModel.scope([
1350 ScopeNames.WITH_BLACKLISTED,
1351 ScopeNames.WITH_USER_ID,
1352 ScopeNames.WITH_THUMBNAILS
1353 ]).findOne(options)
1354 }
1355
1356 static loadOnlyId (id: number | string, t?: Transaction) {
1357 const where = buildWhereIdOrUUID(id)
1205 1358
1206 const options = { 1359 const options = {
1207 attributes: [ 'id' ], 1360 attributes: [ 'id' ],
@@ -1209,12 +1362,15 @@ export class VideoModel extends Model<VideoModel> {
1209 transaction: t 1362 transaction: t
1210 } 1363 }
1211 1364
1212 return VideoModel.findOne(options) 1365 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1213 } 1366 }
1214 1367
1215 static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) { 1368 static loadWithFiles (id: number, t?: Transaction, logging?: boolean) {
1216 return VideoModel.scope(ScopeNames.WITH_FILES) 1369 return VideoModel.scope([
1217 .findById(id, { transaction: t, logging }) 1370 ScopeNames.WITH_FILES,
1371 ScopeNames.WITH_STREAMING_PLAYLISTS,
1372 ScopeNames.WITH_THUMBNAILS
1373 ]).findByPk(id, { transaction: t, logging })
1218 } 1374 }
1219 1375
1220 static loadByUUIDWithFile (uuid: string) { 1376 static loadByUUIDWithFile (uuid: string) {
@@ -1224,52 +1380,85 @@ export class VideoModel extends Model<VideoModel> {
1224 } 1380 }
1225 } 1381 }
1226 1382
1227 return VideoModel 1383 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1228 .scope([ ScopeNames.WITH_FILES ])
1229 .findOne(options)
1230 } 1384 }
1231 1385
1232 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 1386 static loadByUrl (url: string, transaction?: Transaction) {
1233 const query: IFindOptions<VideoModel> = { 1387 const query: FindOptions = {
1234 where: { 1388 where: {
1235 url 1389 url
1236 }, 1390 },
1237 transaction 1391 transaction
1238 } 1392 }
1239 1393
1240 return VideoModel.findOne(query) 1394 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1241 } 1395 }
1242 1396
1243 static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) { 1397 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction) {
1244 const query: IFindOptions<VideoModel> = { 1398 const query: FindOptions = {
1245 where: { 1399 where: {
1246 url 1400 url
1247 }, 1401 },
1248 transaction 1402 transaction
1249 } 1403 }
1250 1404
1251 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) 1405 return VideoModel.scope([
1406 ScopeNames.WITH_ACCOUNT_DETAILS,
1407 ScopeNames.WITH_FILES,
1408 ScopeNames.WITH_STREAMING_PLAYLISTS,
1409 ScopeNames.WITH_THUMBNAILS
1410 ]).findOne(query)
1252 } 1411 }
1253 1412
1254 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1413 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number) {
1255 const where = VideoModel.buildWhereIdOrUUID(id) 1414 const where = buildWhereIdOrUUID(id)
1256 1415
1257 const options = { 1416 const options = {
1258 order: [ [ 'Tags', 'name', 'ASC' ] ], 1417 order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1259 where, 1418 where,
1260 transaction: t 1419 transaction: t
1261 } 1420 }
1262 1421
1263 const scopes = [ 1422 const scopes: (string | ScopeOptions)[] = [
1264 ScopeNames.WITH_TAGS, 1423 ScopeNames.WITH_TAGS,
1265 ScopeNames.WITH_BLACKLISTED, 1424 ScopeNames.WITH_BLACKLISTED,
1425 ScopeNames.WITH_ACCOUNT_DETAILS,
1426 ScopeNames.WITH_SCHEDULED_UPDATE,
1266 ScopeNames.WITH_FILES, 1427 ScopeNames.WITH_FILES,
1428 ScopeNames.WITH_STREAMING_PLAYLISTS,
1429 ScopeNames.WITH_THUMBNAILS
1430 ]
1431
1432 if (userId) {
1433 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1434 }
1435
1436 return VideoModel
1437 .scope(scopes)
1438 .findOne(options)
1439 }
1440
1441 static loadForGetAPI (id: number | string, t?: Transaction, userId?: number) {
1442 const where = buildWhereIdOrUUID(id)
1443
1444 const options = {
1445 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1446 where,
1447 transaction: t
1448 }
1449
1450 const scopes: (string | ScopeOptions)[] = [
1451 ScopeNames.WITH_TAGS,
1452 ScopeNames.WITH_BLACKLISTED,
1267 ScopeNames.WITH_ACCOUNT_DETAILS, 1453 ScopeNames.WITH_ACCOUNT_DETAILS,
1268 ScopeNames.WITH_SCHEDULED_UPDATE 1454 ScopeNames.WITH_SCHEDULED_UPDATE,
1455 ScopeNames.WITH_THUMBNAILS,
1456 { method: [ ScopeNames.WITH_FILES, true ] },
1457 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1269 ] 1458 ]
1270 1459
1271 if (userId) { 1460 if (userId) {
1272 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings 1461 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1273 } 1462 }
1274 1463
1275 return VideoModel 1464 return VideoModel
@@ -1317,7 +1506,7 @@ export class VideoModel extends Model<VideoModel> {
1317 'LIMIT 1' 1506 'LIMIT 1'
1318 1507
1319 const options = { 1508 const options = {
1320 type: Sequelize.QueryTypes.SELECT, 1509 type: QueryTypes.SELECT,
1321 bind: { followerActorId, videoId }, 1510 bind: { followerActorId, videoId },
1322 raw: true 1511 raw: true
1323 } 1512 }
@@ -1334,17 +1523,18 @@ export class VideoModel extends Model<VideoModel> {
1334 const scopeOptions: AvailableForListIDsOptions = { 1523 const scopeOptions: AvailableForListIDsOptions = {
1335 serverAccountId: serverActor.Account.id, 1524 serverAccountId: serverActor.Account.id,
1336 followerActorId, 1525 followerActorId,
1337 includeLocalVideos: true 1526 includeLocalVideos: true,
1527 withoutId: true // Don't break aggregation
1338 } 1528 }
1339 1529
1340 const query: IFindOptions<VideoModel> = { 1530 const query: FindOptions = {
1341 attributes: [ field ], 1531 attributes: [ field ],
1342 limit: count, 1532 limit: count,
1343 group: field, 1533 group: field,
1344 having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), { 1534 having: Sequelize.where(
1345 [ Sequelize.Op.gte ]: threshold 1535 Sequelize.fn('COUNT', Sequelize.col(field)), { [ Op.gte ]: threshold }
1346 }) as any, // FIXME: typings 1536 ),
1347 order: [ this.sequelize.random() ] 1537 order: [ (this.sequelize as any).random() ]
1348 } 1538 }
1349 1539
1350 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] }) 1540 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
@@ -1360,7 +1550,7 @@ export class VideoModel extends Model<VideoModel> {
1360 required: false, 1550 required: false,
1361 where: { 1551 where: {
1362 startDate: { 1552 startDate: {
1363 [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) 1553 [ Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1364 } 1554 }
1365 } 1555 }
1366 } 1556 }
@@ -1377,11 +1567,11 @@ export class VideoModel extends Model<VideoModel> {
1377 } 1567 }
1378 1568
1379 private static async getAvailableForApi ( 1569 private static async getAvailableForApi (
1380 query: IFindOptions<VideoModel>, 1570 query: FindOptions,
1381 options: AvailableForListIDsOptions, 1571 options: AvailableForListIDsOptions,
1382 countVideos = true 1572 countVideos = true
1383 ) { 1573 ) {
1384 const idsScope = { 1574 const idsScope: ScopeOptions = {
1385 method: [ 1575 method: [
1386 ScopeNames.AVAILABLE_FOR_LIST_IDS, options 1576 ScopeNames.AVAILABLE_FOR_LIST_IDS, options
1387 ] 1577 ]
@@ -1389,8 +1579,8 @@ export class VideoModel extends Model<VideoModel> {
1389 1579
1390 // Remove trending sort on count, because it uses a group by 1580 // Remove trending sort on count, because it uses a group by
1391 const countOptions = Object.assign({}, options, { trendingDays: undefined }) 1581 const countOptions = Object.assign({}, options, { trendingDays: undefined })
1392 const countQuery = Object.assign({}, query, { attributes: undefined, group: undefined }) 1582 const countQuery: CountOptions = Object.assign({}, query, { attributes: undefined, group: undefined })
1393 const countScope = { 1583 const countScope: ScopeOptions = {
1394 method: [ 1584 method: [
1395 ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions 1585 ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
1396 ] 1586 ]
@@ -1404,18 +1594,7 @@ export class VideoModel extends Model<VideoModel> {
1404 1594
1405 if (ids.length === 0) return { data: [], total: count } 1595 if (ids.length === 0) return { data: [], total: count }
1406 1596
1407 // FIXME: typings 1597 const secondQuery: FindOptions = {
1408 const apiScope: any[] = [
1409 {
1410 method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ]
1411 }
1412 ]
1413
1414 if (options.user) {
1415 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1416 }
1417
1418 const secondQuery = {
1419 offset: 0, 1598 offset: 0,
1420 limit: query.limit, 1599 limit: query.limit,
1421 attributes: query.attributes, 1600 attributes: query.attributes,
@@ -1425,6 +1604,23 @@ export class VideoModel extends Model<VideoModel> {
1425 ) 1604 )
1426 ] 1605 ]
1427 } 1606 }
1607
1608 const apiScope: (string | ScopeOptions)[] = []
1609
1610 if (options.user) {
1611 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1612 }
1613
1614 apiScope.push({
1615 method: [
1616 ScopeNames.FOR_API, {
1617 ids,
1618 withFiles: options.withFiles,
1619 videoPlaylistId: options.videoPlaylistId
1620 } as ForAPIOptions
1621 ]
1622 })
1623
1428 const rows = await VideoModel.scope(apiScope).findAll(secondQuery) 1624 const rows = await VideoModel.scope(apiScope).findAll(secondQuery)
1429 1625
1430 return { 1626 return {
@@ -1453,10 +1649,6 @@ export class VideoModel extends Model<VideoModel> {
1453 return VIDEO_STATES[ id ] || 'Unknown' 1649 return VIDEO_STATES[ id ] || 'Unknown'
1454 } 1650 }
1455 1651
1456 static buildWhereIdOrUUID (id: number | string) {
1457 return validator.isInt('' + id) ? { id } : { uuid: id }
1458 }
1459
1460 getOriginalFile () { 1652 getOriginalFile () {
1461 if (Array.isArray(this.VideoFiles) === false) return undefined 1653 if (Array.isArray(this.VideoFiles) === false) return undefined
1462 1654
@@ -1464,19 +1656,41 @@ export class VideoModel extends Model<VideoModel> {
1464 return maxBy(this.VideoFiles, file => file.resolution) 1656 return maxBy(this.VideoFiles, file => file.resolution)
1465 } 1657 }
1466 1658
1659 async addAndSaveThumbnail (thumbnail: ThumbnailModel, transaction: Transaction) {
1660 thumbnail.videoId = this.id
1661
1662 const savedThumbnail = await thumbnail.save({ transaction })
1663
1664 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1665
1666 // Already have this thumbnail, skip
1667 if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1668
1669 this.Thumbnails.push(savedThumbnail)
1670 }
1671
1467 getVideoFilename (videoFile: VideoFileModel) { 1672 getVideoFilename (videoFile: VideoFileModel) {
1468 return this.uuid + '-' + videoFile.resolution + videoFile.extname 1673 return this.uuid + '-' + videoFile.resolution + videoFile.extname
1469 } 1674 }
1470 1675
1471 getThumbnailName () { 1676 generateThumbnailName () {
1472 // We always have a copy of the thumbnail 1677 return this.uuid + '.jpg'
1473 const extension = '.jpg'
1474 return this.uuid + extension
1475 } 1678 }
1476 1679
1477 getPreviewName () { 1680 getMiniature () {
1478 const extension = '.jpg' 1681 if (Array.isArray(this.Thumbnails) === false) return undefined
1479 return this.uuid + extension 1682
1683 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1684 }
1685
1686 generatePreviewName () {
1687 return this.uuid + '.jpg'
1688 }
1689
1690 getPreview () {
1691 if (Array.isArray(this.Thumbnails) === false) return undefined
1692
1693 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1480 } 1694 }
1481 1695
1482 getTorrentFileName (videoFile: VideoFileModel) { 1696 getTorrentFileName (videoFile: VideoFileModel) {
@@ -1488,24 +1702,6 @@ export class VideoModel extends Model<VideoModel> {
1488 return this.remote === false 1702 return this.remote === false
1489 } 1703 }
1490 1704
1491 createPreview (videoFile: VideoFileModel) {
1492 return generateImageFromVideoFile(
1493 this.getVideoFilePath(videoFile),
1494 CONFIG.STORAGE.PREVIEWS_DIR,
1495 this.getPreviewName(),
1496 PREVIEWS_SIZE
1497 )
1498 }
1499
1500 createThumbnail (videoFile: VideoFileModel) {
1501 return generateImageFromVideoFile(
1502 this.getVideoFilePath(videoFile),
1503 CONFIG.STORAGE.THUMBNAILS_DIR,
1504 this.getThumbnailName(),
1505 THUMBNAILS_SIZE
1506 )
1507 }
1508
1509 getTorrentFilePath (videoFile: VideoFileModel) { 1705 getTorrentFilePath (videoFile: VideoFileModel) {
1510 return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) 1706 return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1511 } 1707 }
@@ -1520,10 +1716,10 @@ export class VideoModel extends Model<VideoModel> {
1520 name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`, 1716 name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
1521 createdBy: 'PeerTube', 1717 createdBy: 'PeerTube',
1522 announceList: [ 1718 announceList: [
1523 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ], 1719 [ WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' ],
1524 [ CONFIG.WEBSERVER.URL + '/tracker/announce' ] 1720 [ WEBSERVER.URL + '/tracker/announce' ]
1525 ], 1721 ],
1526 urlList: [ CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ] 1722 urlList: [ WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
1527 } 1723 }
1528 1724
1529 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options) 1725 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
@@ -1545,12 +1741,19 @@ export class VideoModel extends Model<VideoModel> {
1545 return '/videos/embed/' + this.uuid 1741 return '/videos/embed/' + this.uuid
1546 } 1742 }
1547 1743
1548 getThumbnailStaticPath () { 1744 getMiniatureStaticPath () {
1549 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) 1745 const thumbnail = this.getMiniature()
1746 if (!thumbnail) return null
1747
1748 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1550 } 1749 }
1551 1750
1552 getPreviewStaticPath () { 1751 getPreviewStaticPath () {
1553 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) 1752 const preview = this.getPreview()
1753 if (!preview) return null
1754
1755 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1756 return join(STATIC_PATHS.PREVIEWS, preview.filename)
1554 } 1757 }
1555 1758
1556 toFormattedJSON (options?: VideoFormattingJSONOptions): Video { 1759 toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
@@ -1586,18 +1789,6 @@ export class VideoModel extends Model<VideoModel> {
1586 return `/api/${API_VERSION}/videos/${this.uuid}/description` 1789 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1587 } 1790 }
1588 1791
1589 removeThumbnail () {
1590 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1591 return remove(thumbnailPath)
1592 .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
1593 }
1594
1595 removePreview () {
1596 const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1597 return remove(previewPath)
1598 .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
1599 }
1600
1601 removeFile (videoFile: VideoFileModel, isRedundancy = false) { 1792 removeFile (videoFile: VideoFileModel, isRedundancy = false) {
1602 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR 1793 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
1603 1794
@@ -1612,15 +1803,18 @@ export class VideoModel extends Model<VideoModel> {
1612 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1803 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1613 } 1804 }
1614 1805
1806 removeStreamingPlaylist (isRedundancy = false) {
1807 const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_STREAMING_PLAYLIST_DIRECTORY
1808
1809 const filePath = join(baseDir, this.uuid)
1810 return remove(filePath)
1811 .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1812 }
1813
1615 isOutdated () { 1814 isOutdated () {
1616 if (this.isOwned()) return false 1815 if (this.isOwned()) return false
1617 1816
1618 const now = Date.now() 1817 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1619 const createdAtTime = this.createdAt.getTime()
1620 const updatedAtTime = this.updatedAt.getTime()
1621
1622 return (now - createdAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL &&
1623 (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL
1624 } 1818 }
1625 1819
1626 setAsRefreshed () { 1820 setAsRefreshed () {
@@ -1634,8 +1828,8 @@ export class VideoModel extends Model<VideoModel> {
1634 let baseUrlWs 1828 let baseUrlWs
1635 1829
1636 if (this.isOwned()) { 1830 if (this.isOwned()) {
1637 baseUrlHttp = CONFIG.WEBSERVER.URL 1831 baseUrlHttp = WEBSERVER.URL
1638 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT 1832 baseUrlWs = WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
1639 } else { 1833 } else {
1640 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host 1834 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1641 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host 1835 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
@@ -1646,7 +1840,7 @@ export class VideoModel extends Model<VideoModel> {
1646 1840
1647 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { 1841 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1648 const xs = this.getTorrentUrl(videoFile, baseUrlHttp) 1842 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1649 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] 1843 const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1650 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] 1844 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1651 1845
1652 const redundancies = videoFile.RedundancyVideos 1846 const redundancies = videoFile.RedundancyVideos
@@ -1663,8 +1857,8 @@ export class VideoModel extends Model<VideoModel> {
1663 return magnetUtil.encode(magnetHash) 1857 return magnetUtil.encode(magnetHash)
1664 } 1858 }
1665 1859
1666 getThumbnailUrl (baseUrlHttp: string) { 1860 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1667 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() 1861 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1668 } 1862 }
1669 1863
1670 getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1864 getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
@@ -1686,4 +1880,8 @@ export class VideoModel extends Model<VideoModel> {
1686 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1880 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1687 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) 1881 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1688 } 1882 }
1883
1884 getBandwidthBits (videoFile: VideoFileModel) {
1885 return Math.ceil((videoFile.size * 8) / this.duration)
1886 }
1689} 1887}