diff options
Diffstat (limited to 'server/models/video')
-rw-r--r-- | server/models/video/thumbnail.ts | 15 | ||||
-rw-r--r-- | server/models/video/video-abuse.ts | 339 | ||||
-rw-r--r-- | server/models/video/video-blacklist.ts | 13 | ||||
-rw-r--r-- | server/models/video/video-caption.ts | 27 | ||||
-rw-r--r-- | server/models/video/video-channel.ts | 176 | ||||
-rw-r--r-- | server/models/video/video-comment.ts | 41 | ||||
-rw-r--r-- | server/models/video/video-file.ts | 81 | ||||
-rw-r--r-- | server/models/video/video-format-utils.ts | 53 | ||||
-rw-r--r-- | server/models/video/video-import.ts | 1 | ||||
-rw-r--r-- | server/models/video/video-playlist-element.ts | 13 | ||||
-rw-r--r-- | server/models/video/video-playlist.ts | 42 | ||||
-rw-r--r-- | server/models/video/video-query-builder.ts | 503 | ||||
-rw-r--r-- | server/models/video/video-share.ts | 89 | ||||
-rw-r--r-- | server/models/video/video.ts | 976 |
14 files changed, 1562 insertions, 807 deletions
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts index 3b011b1d2..e396784d2 100644 --- a/server/models/video/thumbnail.ts +++ b/server/models/video/thumbnail.ts | |||
@@ -19,6 +19,8 @@ import { CONFIG } from '../../initializers/config' | |||
19 | import { VideoModel } from './video' | 19 | import { VideoModel } from './video' |
20 | import { VideoPlaylistModel } from './video-playlist' | 20 | import { VideoPlaylistModel } from './video-playlist' |
21 | import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' | 21 | import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' |
22 | import { MVideoAccountLight } from '@server/typings/models' | ||
23 | import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' | ||
22 | 24 | ||
23 | @Table({ | 25 | @Table({ |
24 | tableName: 'thumbnail', | 26 | tableName: 'thumbnail', |
@@ -90,7 +92,7 @@ export class ThumbnailModel extends Model<ThumbnailModel> { | |||
90 | @UpdatedAt | 92 | @UpdatedAt |
91 | updatedAt: Date | 93 | updatedAt: Date |
92 | 94 | ||
93 | private static types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = { | 95 | private static readonly types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = { |
94 | [ThumbnailType.MINIATURE]: { | 96 | [ThumbnailType.MINIATURE]: { |
95 | label: 'miniature', | 97 | label: 'miniature', |
96 | directory: CONFIG.STORAGE.THUMBNAILS_DIR, | 98 | directory: CONFIG.STORAGE.THUMBNAILS_DIR, |
@@ -126,11 +128,14 @@ export class ThumbnailModel extends Model<ThumbnailModel> { | |||
126 | return videoUUID + '.jpg' | 128 | return videoUUID + '.jpg' |
127 | } | 129 | } |
128 | 130 | ||
129 | getFileUrl (isLocal: boolean) { | 131 | getFileUrl (video: MVideoAccountLight) { |
130 | if (isLocal === false) return this.fileUrl | 132 | const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename |
131 | 133 | ||
132 | const staticPath = ThumbnailModel.types[this.type].staticPath | 134 | if (video.isOwned()) return WEBSERVER.URL + staticPath |
133 | return WEBSERVER.URL + staticPath + this.filename | 135 | if (this.fileUrl) return this.fileUrl |
136 | |||
137 | // Fallback if we don't have a file URL | ||
138 | return buildRemoteVideoBaseUrl(video, staticPath) | ||
134 | } | 139 | } |
135 | 140 | ||
136 | getPath () { | 141 | getPath () { |
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts index 3636db18d..0844f702d 100644 --- a/server/models/video/video-abuse.ts +++ b/server/models/video/video-abuse.ts | |||
@@ -1,4 +1,21 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import * as Bluebird from 'bluebird' |
2 | import { literal, Op } from 'sequelize' | ||
3 | import { | ||
4 | AllowNull, | ||
5 | BelongsTo, | ||
6 | Column, | ||
7 | CreatedAt, | ||
8 | DataType, | ||
9 | Default, | ||
10 | ForeignKey, | ||
11 | Is, | ||
12 | Model, | ||
13 | Scopes, | ||
14 | Table, | ||
15 | UpdatedAt | ||
16 | } from 'sequelize-typescript' | ||
17 | import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' | ||
18 | import { VideoAbuseState, VideoDetails } from '../../../shared' | ||
2 | import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' | 19 | import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' |
3 | import { VideoAbuse } from '../../../shared/models/videos' | 20 | import { VideoAbuse } from '../../../shared/models/videos' |
4 | import { | 21 | import { |
@@ -6,15 +23,205 @@ import { | |||
6 | isVideoAbuseReasonValid, | 23 | isVideoAbuseReasonValid, |
7 | isVideoAbuseStateValid | 24 | isVideoAbuseStateValid |
8 | } from '../../helpers/custom-validators/video-abuses' | 25 | } from '../../helpers/custom-validators/video-abuses' |
9 | import { AccountModel } from '../account/account' | ||
10 | import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils' | ||
11 | import { VideoModel } from './video' | ||
12 | import { VideoAbuseState } from '../../../shared' | ||
13 | import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' | 26 | import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' |
14 | import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../typings/models' | 27 | import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../typings/models' |
15 | import * as Bluebird from 'bluebird' | 28 | import { AccountModel } from '../account/account' |
16 | import { literal, Op } from 'sequelize' | 29 | import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils' |
30 | import { ThumbnailModel } from './thumbnail' | ||
31 | import { VideoModel } from './video' | ||
32 | import { VideoBlacklistModel } from './video-blacklist' | ||
33 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | ||
34 | |||
35 | export enum ScopeNames { | ||
36 | FOR_API = 'FOR_API' | ||
37 | } | ||
38 | |||
39 | @Scopes(() => ({ | ||
40 | [ScopeNames.FOR_API]: (options: { | ||
41 | // search | ||
42 | search?: string | ||
43 | searchReporter?: string | ||
44 | searchReportee?: string | ||
45 | searchVideo?: string | ||
46 | searchVideoChannel?: string | ||
47 | |||
48 | // filters | ||
49 | id?: number | ||
50 | |||
51 | state?: VideoAbuseState | ||
52 | videoIs?: VideoAbuseVideoIs | ||
53 | |||
54 | // accountIds | ||
55 | serverAccountId: number | ||
56 | userAccountId: number | ||
57 | }) => { | ||
58 | const where = { | ||
59 | reporterAccountId: { | ||
60 | [Op.notIn]: literal('(' + buildBlockedAccountSQL(options.serverAccountId, options.userAccountId) + ')') | ||
61 | } | ||
62 | } | ||
63 | |||
64 | if (options.search) { | ||
65 | Object.assign(where, { | ||
66 | [Op.or]: [ | ||
67 | { | ||
68 | [Op.and]: [ | ||
69 | { videoId: { [Op.not]: null } }, | ||
70 | searchAttribute(options.search, '$Video.name$') | ||
71 | ] | ||
72 | }, | ||
73 | { | ||
74 | [Op.and]: [ | ||
75 | { videoId: { [Op.not]: null } }, | ||
76 | searchAttribute(options.search, '$Video.VideoChannel.name$') | ||
77 | ] | ||
78 | }, | ||
79 | { | ||
80 | [Op.and]: [ | ||
81 | { deletedVideo: { [Op.not]: null } }, | ||
82 | { deletedVideo: searchAttribute(options.search, 'name') } | ||
83 | ] | ||
84 | }, | ||
85 | { | ||
86 | [Op.and]: [ | ||
87 | { deletedVideo: { [Op.not]: null } }, | ||
88 | { deletedVideo: { channel: searchAttribute(options.search, 'displayName') } } | ||
89 | ] | ||
90 | }, | ||
91 | searchAttribute(options.search, '$Account.name$') | ||
92 | ] | ||
93 | }) | ||
94 | } | ||
17 | 95 | ||
96 | if (options.id) Object.assign(where, { id: options.id }) | ||
97 | if (options.state) Object.assign(where, { state: options.state }) | ||
98 | |||
99 | if (options.videoIs === 'deleted') { | ||
100 | Object.assign(where, { | ||
101 | deletedVideo: { | ||
102 | [Op.not]: null | ||
103 | } | ||
104 | }) | ||
105 | } | ||
106 | |||
107 | const onlyBlacklisted = options.videoIs === 'blacklisted' | ||
108 | |||
109 | return { | ||
110 | attributes: { | ||
111 | include: [ | ||
112 | [ | ||
113 | // we don't care about this count for deleted videos, so there are not included | ||
114 | literal( | ||
115 | '(' + | ||
116 | 'SELECT count(*) ' + | ||
117 | 'FROM "videoAbuse" ' + | ||
118 | 'WHERE "videoId" = "VideoAbuseModel"."videoId" ' + | ||
119 | ')' | ||
120 | ), | ||
121 | 'countReportsForVideo' | ||
122 | ], | ||
123 | [ | ||
124 | // we don't care about this count for deleted videos, so there are not included | ||
125 | literal( | ||
126 | '(' + | ||
127 | 'SELECT t.nth ' + | ||
128 | 'FROM ( ' + | ||
129 | 'SELECT id, ' + | ||
130 | 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' + | ||
131 | 'FROM "videoAbuse" ' + | ||
132 | ') t ' + | ||
133 | 'WHERE t.id = "VideoAbuseModel".id ' + | ||
134 | ')' | ||
135 | ), | ||
136 | 'nthReportForVideo' | ||
137 | ], | ||
138 | [ | ||
139 | literal( | ||
140 | '(' + | ||
141 | 'SELECT count("videoAbuse"."id") ' + | ||
142 | 'FROM "videoAbuse" ' + | ||
143 | 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' + | ||
144 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
145 | 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + | ||
146 | 'WHERE "account"."id" = "VideoAbuseModel"."reporterAccountId" ' + | ||
147 | ')' | ||
148 | ), | ||
149 | 'countReportsForReporter__video' | ||
150 | ], | ||
151 | [ | ||
152 | literal( | ||
153 | '(' + | ||
154 | 'SELECT count(DISTINCT "videoAbuse"."id") ' + | ||
155 | 'FROM "videoAbuse" ' + | ||
156 | `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuseModel"."reporterAccountId" ` + | ||
157 | ')' | ||
158 | ), | ||
159 | 'countReportsForReporter__deletedVideo' | ||
160 | ], | ||
161 | [ | ||
162 | literal( | ||
163 | '(' + | ||
164 | 'SELECT count(DISTINCT "videoAbuse"."id") ' + | ||
165 | 'FROM "videoAbuse" ' + | ||
166 | 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' + | ||
167 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
168 | 'INNER JOIN "account" ON ' + | ||
169 | '"videoChannel"."accountId" = "Video->VideoChannel"."accountId" ' + | ||
170 | `OR "videoChannel"."accountId" = CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` + | ||
171 | ')' | ||
172 | ), | ||
173 | 'countReportsForReportee__video' | ||
174 | ], | ||
175 | [ | ||
176 | literal( | ||
177 | '(' + | ||
178 | 'SELECT count(DISTINCT "videoAbuse"."id") ' + | ||
179 | 'FROM "videoAbuse" ' + | ||
180 | `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "Video->VideoChannel"."accountId" ` + | ||
181 | `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` + | ||
182 | `CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` + | ||
183 | ')' | ||
184 | ), | ||
185 | 'countReportsForReportee__deletedVideo' | ||
186 | ] | ||
187 | ] | ||
188 | }, | ||
189 | include: [ | ||
190 | { | ||
191 | model: AccountModel, | ||
192 | required: true, | ||
193 | where: searchAttribute(options.searchReporter, 'name') | ||
194 | }, | ||
195 | { | ||
196 | model: VideoModel, | ||
197 | required: !!(onlyBlacklisted || options.searchVideo || options.searchReportee || options.searchVideoChannel), | ||
198 | where: searchAttribute(options.searchVideo, 'name'), | ||
199 | include: [ | ||
200 | { | ||
201 | model: ThumbnailModel | ||
202 | }, | ||
203 | { | ||
204 | model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), | ||
205 | where: searchAttribute(options.searchVideoChannel, 'name'), | ||
206 | include: [ | ||
207 | { | ||
208 | model: AccountModel, | ||
209 | where: searchAttribute(options.searchReportee, 'name') | ||
210 | } | ||
211 | ] | ||
212 | }, | ||
213 | { | ||
214 | attributes: [ 'id', 'reason', 'unfederated' ], | ||
215 | model: VideoBlacklistModel, | ||
216 | required: onlyBlacklisted | ||
217 | } | ||
218 | ] | ||
219 | } | ||
220 | ], | ||
221 | where | ||
222 | } | ||
223 | } | ||
224 | })) | ||
18 | @Table({ | 225 | @Table({ |
19 | tableName: 'videoAbuse', | 226 | tableName: 'videoAbuse', |
20 | indexes: [ | 227 | indexes: [ |
@@ -46,6 +253,11 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
46 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max)) | 253 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max)) |
47 | moderationComment: string | 254 | moderationComment: string |
48 | 255 | ||
256 | @AllowNull(true) | ||
257 | @Default(null) | ||
258 | @Column(DataType.JSONB) | ||
259 | deletedVideo: VideoDetails | ||
260 | |||
49 | @CreatedAt | 261 | @CreatedAt |
50 | createdAt: Date | 262 | createdAt: Date |
51 | 263 | ||
@@ -58,9 +270,9 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
58 | 270 | ||
59 | @BelongsTo(() => AccountModel, { | 271 | @BelongsTo(() => AccountModel, { |
60 | foreignKey: { | 272 | foreignKey: { |
61 | allowNull: false | 273 | allowNull: true |
62 | }, | 274 | }, |
63 | onDelete: 'cascade' | 275 | onDelete: 'set null' |
64 | }) | 276 | }) |
65 | Account: AccountModel | 277 | Account: AccountModel |
66 | 278 | ||
@@ -70,60 +282,103 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
70 | 282 | ||
71 | @BelongsTo(() => VideoModel, { | 283 | @BelongsTo(() => VideoModel, { |
72 | foreignKey: { | 284 | foreignKey: { |
73 | allowNull: false | 285 | allowNull: true |
74 | }, | 286 | }, |
75 | onDelete: 'cascade' | 287 | onDelete: 'set null' |
76 | }) | 288 | }) |
77 | Video: VideoModel | 289 | Video: VideoModel |
78 | 290 | ||
79 | static loadByIdAndVideoId (id: number, videoId: number): Bluebird<MVideoAbuse> { | 291 | static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MVideoAbuse> { |
292 | const videoAttributes = {} | ||
293 | if (videoId) videoAttributes['videoId'] = videoId | ||
294 | if (uuid) videoAttributes['deletedVideo'] = { uuid } | ||
295 | |||
80 | const query = { | 296 | const query = { |
81 | where: { | 297 | where: { |
82 | id, | 298 | id, |
83 | videoId | 299 | ...videoAttributes |
84 | } | 300 | } |
85 | } | 301 | } |
86 | return VideoAbuseModel.findOne(query) | 302 | return VideoAbuseModel.findOne(query) |
87 | } | 303 | } |
88 | 304 | ||
89 | static listForApi (parameters: { | 305 | static listForApi (parameters: { |
90 | start: number, | 306 | start: number |
91 | count: number, | 307 | count: number |
92 | sort: string, | 308 | sort: string |
309 | |||
93 | serverAccountId: number | 310 | serverAccountId: number |
94 | user?: MUserAccountId | 311 | user?: MUserAccountId |
312 | |||
313 | id?: number | ||
314 | state?: VideoAbuseState | ||
315 | videoIs?: VideoAbuseVideoIs | ||
316 | |||
317 | search?: string | ||
318 | searchReporter?: string | ||
319 | searchReportee?: string | ||
320 | searchVideo?: string | ||
321 | searchVideoChannel?: string | ||
95 | }) { | 322 | }) { |
96 | const { start, count, sort, user, serverAccountId } = parameters | 323 | const { |
324 | start, | ||
325 | count, | ||
326 | sort, | ||
327 | search, | ||
328 | user, | ||
329 | serverAccountId, | ||
330 | state, | ||
331 | videoIs, | ||
332 | searchReportee, | ||
333 | searchVideo, | ||
334 | searchVideoChannel, | ||
335 | searchReporter, | ||
336 | id | ||
337 | } = parameters | ||
338 | |||
97 | const userAccountId = user ? user.Account.id : undefined | 339 | const userAccountId = user ? user.Account.id : undefined |
98 | 340 | ||
99 | const query = { | 341 | const query = { |
100 | offset: start, | 342 | offset: start, |
101 | limit: count, | 343 | limit: count, |
102 | order: getSort(sort), | 344 | order: getSort(sort), |
103 | where: { | 345 | col: 'VideoAbuseModel.id', |
104 | reporterAccountId: { | 346 | distinct: true |
105 | [Op.notIn]: literal('(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')') | ||
106 | } | ||
107 | }, | ||
108 | include: [ | ||
109 | { | ||
110 | model: AccountModel, | ||
111 | required: true | ||
112 | }, | ||
113 | { | ||
114 | model: VideoModel, | ||
115 | required: true | ||
116 | } | ||
117 | ] | ||
118 | } | 347 | } |
119 | 348 | ||
120 | return VideoAbuseModel.findAndCountAll(query) | 349 | const filters = { |
350 | id, | ||
351 | search, | ||
352 | state, | ||
353 | videoIs, | ||
354 | searchReportee, | ||
355 | searchVideo, | ||
356 | searchVideoChannel, | ||
357 | searchReporter, | ||
358 | serverAccountId, | ||
359 | userAccountId | ||
360 | } | ||
361 | |||
362 | return VideoAbuseModel | ||
363 | .scope({ method: [ ScopeNames.FOR_API, filters ] }) | ||
364 | .findAndCountAll(query) | ||
121 | .then(({ rows, count }) => { | 365 | .then(({ rows, count }) => { |
122 | return { total: count, data: rows } | 366 | return { total: count, data: rows } |
123 | }) | 367 | }) |
124 | } | 368 | } |
125 | 369 | ||
126 | toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse { | 370 | toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse { |
371 | const countReportsForVideo = this.get('countReportsForVideo') as number | ||
372 | const nthReportForVideo = this.get('nthReportForVideo') as number | ||
373 | const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number | ||
374 | const countReportsForReporterDeletedVideo = this.get('countReportsForReporter__deletedVideo') as number | ||
375 | const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number | ||
376 | const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number | ||
377 | |||
378 | const video = this.Video | ||
379 | ? this.Video | ||
380 | : this.deletedVideo | ||
381 | |||
127 | return { | 382 | return { |
128 | id: this.id, | 383 | id: this.id, |
129 | reason: this.reason, | 384 | reason: this.reason, |
@@ -134,11 +389,21 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
134 | }, | 389 | }, |
135 | moderationComment: this.moderationComment, | 390 | moderationComment: this.moderationComment, |
136 | video: { | 391 | video: { |
137 | id: this.Video.id, | 392 | id: video.id, |
138 | uuid: this.Video.uuid, | 393 | uuid: video.uuid, |
139 | name: this.Video.name | 394 | name: video.name, |
395 | nsfw: video.nsfw, | ||
396 | deleted: !this.Video, | ||
397 | blacklisted: this.Video && this.Video.isBlacklisted(), | ||
398 | thumbnailPath: this.Video?.getMiniatureStaticPath(), | ||
399 | channel: this.Video?.VideoChannel.toFormattedJSON() || this.deletedVideo?.channel | ||
140 | }, | 400 | }, |
141 | createdAt: this.createdAt | 401 | createdAt: this.createdAt, |
402 | updatedAt: this.updatedAt, | ||
403 | count: countReportsForVideo || 0, | ||
404 | nth: nthReportForVideo || 0, | ||
405 | countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0), | ||
406 | countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0) | ||
142 | } | 407 | } |
143 | } | 408 | } |
144 | 409 | ||
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts index 694983cb3..8cbfe362e 100644 --- a/server/models/video/video-blacklist.ts +++ b/server/models/video/video-blacklist.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' |
2 | import { getBlacklistSort, SortType, throwIfNotValid } from '../utils' | 2 | import { getBlacklistSort, SortType, throwIfNotValid, searchAttribute } from '../utils' |
3 | import { VideoModel } from './video' | 3 | import { VideoModel } from './video' |
4 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | 4 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' |
5 | import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' | 5 | import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' |
@@ -54,7 +54,15 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> { | |||
54 | }) | 54 | }) |
55 | Video: VideoModel | 55 | Video: VideoModel |
56 | 56 | ||
57 | static listForApi (start: number, count: number, sort: SortType, type?: VideoBlacklistType) { | 57 | static listForApi (parameters: { |
58 | start: number | ||
59 | count: number | ||
60 | sort: SortType | ||
61 | search?: string | ||
62 | type?: VideoBlacklistType | ||
63 | }) { | ||
64 | const { start, count, sort, search, type } = parameters | ||
65 | |||
58 | function buildBaseQuery (): FindOptions { | 66 | function buildBaseQuery (): FindOptions { |
59 | return { | 67 | return { |
60 | offset: start, | 68 | offset: start, |
@@ -70,6 +78,7 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> { | |||
70 | { | 78 | { |
71 | model: VideoModel, | 79 | model: VideoModel, |
72 | required: true, | 80 | required: true, |
81 | where: searchAttribute(search, 'name'), | ||
73 | include: [ | 82 | include: [ |
74 | { | 83 | { |
75 | model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), | 84 | model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), |
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts index eeb2a4afd..59d3e1050 100644 --- a/server/models/video/video-caption.ts +++ b/server/models/video/video-caption.ts | |||
@@ -5,6 +5,7 @@ import { | |||
5 | BelongsTo, | 5 | BelongsTo, |
6 | Column, | 6 | Column, |
7 | CreatedAt, | 7 | CreatedAt, |
8 | DataType, | ||
8 | ForeignKey, | 9 | ForeignKey, |
9 | Is, | 10 | Is, |
10 | Model, | 11 | Model, |
@@ -16,13 +17,14 @@ import { buildWhereIdOrUUID, throwIfNotValid } from '../utils' | |||
16 | import { VideoModel } from './video' | 17 | import { VideoModel } from './video' |
17 | import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' | 18 | import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' |
18 | import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' | 19 | import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' |
19 | import { LAZY_STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers/constants' | 20 | import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' |
20 | import { join } from 'path' | 21 | import { join } from 'path' |
21 | import { logger } from '../../helpers/logger' | 22 | import { logger } from '../../helpers/logger' |
22 | import { remove } from 'fs-extra' | 23 | import { remove } from 'fs-extra' |
23 | import { CONFIG } from '../../initializers/config' | 24 | import { CONFIG } from '../../initializers/config' |
24 | import * as Bluebird from 'bluebird' | 25 | import * as Bluebird from 'bluebird' |
25 | import { MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/typings/models' | 26 | import { MVideoAccountLight, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/typings/models' |
27 | import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' | ||
26 | 28 | ||
27 | export enum ScopeNames { | 29 | export enum ScopeNames { |
28 | WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' | 30 | WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' |
@@ -64,6 +66,10 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> { | |||
64 | @Column | 66 | @Column |
65 | language: string | 67 | language: string |
66 | 68 | ||
69 | @AllowNull(true) | ||
70 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max)) | ||
71 | fileUrl: string | ||
72 | |||
67 | @ForeignKey(() => VideoModel) | 73 | @ForeignKey(() => VideoModel) |
68 | @Column | 74 | @Column |
69 | videoId: number | 75 | videoId: number |
@@ -114,13 +120,14 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> { | |||
114 | return VideoCaptionModel.findOne(query) | 120 | return VideoCaptionModel.findOne(query) |
115 | } | 121 | } |
116 | 122 | ||
117 | static insertOrReplaceLanguage (videoId: number, language: string, transaction: Transaction) { | 123 | static insertOrReplaceLanguage (videoId: number, language: string, fileUrl: string, transaction: Transaction) { |
118 | const values = { | 124 | const values = { |
119 | videoId, | 125 | videoId, |
120 | language | 126 | language, |
127 | fileUrl | ||
121 | } | 128 | } |
122 | 129 | ||
123 | return (VideoCaptionModel.upsert<VideoCaptionModel>(values, { transaction, returning: true }) as any) // FIXME: typings | 130 | return VideoCaptionModel.upsert(values, { transaction, returning: true }) |
124 | .then(([ caption ]) => caption) | 131 | .then(([ caption ]) => caption) |
125 | } | 132 | } |
126 | 133 | ||
@@ -175,4 +182,14 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> { | |||
175 | removeCaptionFile (this: MVideoCaptionFormattable) { | 182 | removeCaptionFile (this: MVideoCaptionFormattable) { |
176 | return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName()) | 183 | return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName()) |
177 | } | 184 | } |
185 | |||
186 | getFileUrl (video: MVideoAccountLight) { | ||
187 | if (!this.Video) this.Video = video as VideoModel | ||
188 | |||
189 | if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath() | ||
190 | if (this.fileUrl) return this.fileUrl | ||
191 | |||
192 | // Fallback if we don't have a file URL | ||
193 | return buildRemoteVideoBaseUrl(video, this.getCaptionStaticPath()) | ||
194 | } | ||
178 | } | 195 | } |
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index e10adcb3a..642e129ff 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts | |||
@@ -30,7 +30,7 @@ import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttr | |||
30 | import { VideoModel } from './video' | 30 | import { VideoModel } from './video' |
31 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | 31 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' |
32 | import { ServerModel } from '../server/server' | 32 | import { ServerModel } from '../server/server' |
33 | import { FindOptions, ModelIndexesOptions, Op } from 'sequelize' | 33 | import { FindOptions, Op, literal, ScopeOptions } from 'sequelize' |
34 | import { AvatarModel } from '../avatar/avatar' | 34 | import { AvatarModel } from '../avatar/avatar' |
35 | import { VideoPlaylistModel } from './video-playlist' | 35 | import { VideoPlaylistModel } from './video-playlist' |
36 | import * as Bluebird from 'bluebird' | 36 | import * as Bluebird from 'bluebird' |
@@ -43,30 +43,23 @@ import { | |||
43 | MChannelSummaryFormattable | 43 | MChannelSummaryFormattable |
44 | } from '../../typings/models/video' | 44 | } from '../../typings/models/video' |
45 | 45 | ||
46 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | ||
47 | const indexes: ModelIndexesOptions[] = [ | ||
48 | buildTrigramSearchIndex('video_channel_name_trigram', 'name'), | ||
49 | |||
50 | { | ||
51 | fields: [ 'accountId' ] | ||
52 | }, | ||
53 | { | ||
54 | fields: [ 'actorId' ] | ||
55 | } | ||
56 | ] | ||
57 | |||
58 | export enum ScopeNames { | 46 | export enum ScopeNames { |
59 | FOR_API = 'FOR_API', | 47 | FOR_API = 'FOR_API', |
48 | SUMMARY = 'SUMMARY', | ||
60 | WITH_ACCOUNT = 'WITH_ACCOUNT', | 49 | WITH_ACCOUNT = 'WITH_ACCOUNT', |
61 | WITH_ACTOR = 'WITH_ACTOR', | 50 | WITH_ACTOR = 'WITH_ACTOR', |
62 | WITH_VIDEOS = 'WITH_VIDEOS', | 51 | WITH_VIDEOS = 'WITH_VIDEOS', |
63 | SUMMARY = 'SUMMARY' | 52 | WITH_STATS = 'WITH_STATS' |
64 | } | 53 | } |
65 | 54 | ||
66 | type AvailableForListOptions = { | 55 | type AvailableForListOptions = { |
67 | actorId: number | 56 | actorId: number |
68 | } | 57 | } |
69 | 58 | ||
59 | type AvailableWithStatsOptions = { | ||
60 | daysPrior: number | ||
61 | } | ||
62 | |||
70 | export type SummaryOptions = { | 63 | export type SummaryOptions = { |
71 | withAccount?: boolean // Default: false | 64 | withAccount?: boolean // Default: false |
72 | withAccountBlockerIds?: number[] | 65 | withAccountBlockerIds?: number[] |
@@ -81,40 +74,6 @@ export type SummaryOptions = { | |||
81 | ] | 74 | ] |
82 | })) | 75 | })) |
83 | @Scopes(() => ({ | 76 | @Scopes(() => ({ |
84 | [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { | ||
85 | const base: FindOptions = { | ||
86 | attributes: [ 'id', 'name', 'description', 'actorId' ], | ||
87 | include: [ | ||
88 | { | ||
89 | attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
90 | model: ActorModel.unscoped(), | ||
91 | required: true, | ||
92 | include: [ | ||
93 | { | ||
94 | attributes: [ 'host' ], | ||
95 | model: ServerModel.unscoped(), | ||
96 | required: false | ||
97 | }, | ||
98 | { | ||
99 | model: AvatarModel.unscoped(), | ||
100 | required: false | ||
101 | } | ||
102 | ] | ||
103 | } | ||
104 | ] | ||
105 | } | ||
106 | |||
107 | if (options.withAccount === true) { | ||
108 | base.include.push({ | ||
109 | model: AccountModel.scope({ | ||
110 | method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ] | ||
111 | }), | ||
112 | required: true | ||
113 | }) | ||
114 | } | ||
115 | |||
116 | return base | ||
117 | }, | ||
118 | [ScopeNames.FOR_API]: (options: AvailableForListOptions) => { | 77 | [ScopeNames.FOR_API]: (options: AvailableForListOptions) => { |
119 | // Only list local channels OR channels that are on an instance followed by actorId | 78 | // Only list local channels OR channels that are on an instance followed by actorId |
120 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) | 79 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) |
@@ -133,7 +92,7 @@ export type SummaryOptions = { | |||
133 | }, | 92 | }, |
134 | { | 93 | { |
135 | serverId: { | 94 | serverId: { |
136 | [ Op.in ]: Sequelize.literal(inQueryInstanceFollow) | 95 | [Op.in]: Sequelize.literal(inQueryInstanceFollow) |
137 | } | 96 | } |
138 | } | 97 | } |
139 | ] | 98 | ] |
@@ -155,6 +114,40 @@ export type SummaryOptions = { | |||
155 | ] | 114 | ] |
156 | } | 115 | } |
157 | }, | 116 | }, |
117 | [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { | ||
118 | const base: FindOptions = { | ||
119 | attributes: [ 'id', 'name', 'description', 'actorId' ], | ||
120 | include: [ | ||
121 | { | ||
122 | attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
123 | model: ActorModel.unscoped(), | ||
124 | required: true, | ||
125 | include: [ | ||
126 | { | ||
127 | attributes: [ 'host' ], | ||
128 | model: ServerModel.unscoped(), | ||
129 | required: false | ||
130 | }, | ||
131 | { | ||
132 | model: AvatarModel.unscoped(), | ||
133 | required: false | ||
134 | } | ||
135 | ] | ||
136 | } | ||
137 | ] | ||
138 | } | ||
139 | |||
140 | if (options.withAccount === true) { | ||
141 | base.include.push({ | ||
142 | model: AccountModel.scope({ | ||
143 | method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ] | ||
144 | }), | ||
145 | required: true | ||
146 | }) | ||
147 | } | ||
148 | |||
149 | return base | ||
150 | }, | ||
158 | [ScopeNames.WITH_ACCOUNT]: { | 151 | [ScopeNames.WITH_ACCOUNT]: { |
159 | include: [ | 152 | include: [ |
160 | { | 153 | { |
@@ -163,20 +156,66 @@ export type SummaryOptions = { | |||
163 | } | 156 | } |
164 | ] | 157 | ] |
165 | }, | 158 | }, |
166 | [ScopeNames.WITH_VIDEOS]: { | 159 | [ScopeNames.WITH_ACTOR]: { |
167 | include: [ | 160 | include: [ |
168 | VideoModel | 161 | ActorModel |
169 | ] | 162 | ] |
170 | }, | 163 | }, |
171 | [ScopeNames.WITH_ACTOR]: { | 164 | [ScopeNames.WITH_VIDEOS]: { |
172 | include: [ | 165 | include: [ |
173 | ActorModel | 166 | VideoModel |
174 | ] | 167 | ] |
168 | }, | ||
169 | [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => { | ||
170 | const daysPrior = parseInt(options.daysPrior + '', 10) | ||
171 | |||
172 | return { | ||
173 | attributes: { | ||
174 | include: [ | ||
175 | [ | ||
176 | literal( | ||
177 | '(' + | ||
178 | `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` + | ||
179 | 'FROM ( ' + | ||
180 | 'WITH ' + | ||
181 | 'days AS ( ' + | ||
182 | `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` + | ||
183 | `date_trunc('day', now()), '1 day'::interval) AS day ` + | ||
184 | '), ' + | ||
185 | 'views AS ( ' + | ||
186 | 'SELECT v.* ' + | ||
187 | 'FROM "videoView" AS v ' + | ||
188 | 'INNER JOIN "video" ON "video"."id" = v."videoId" ' + | ||
189 | 'WHERE "video"."channelId" = "VideoChannelModel"."id" ' + | ||
190 | ') ' + | ||
191 | 'SELECT days.day AS day, ' + | ||
192 | 'COALESCE(SUM(views.views), 0) AS views ' + | ||
193 | 'FROM days ' + | ||
194 | `LEFT JOIN views ON date_trunc('day', "views"."startDate") = date_trunc('day', days.day) ` + | ||
195 | 'GROUP BY day ' + | ||
196 | 'ORDER BY day ' + | ||
197 | ') t' + | ||
198 | ')' | ||
199 | ), | ||
200 | 'viewsPerDay' | ||
201 | ] | ||
202 | ] | ||
203 | } | ||
204 | } | ||
175 | } | 205 | } |
176 | })) | 206 | })) |
177 | @Table({ | 207 | @Table({ |
178 | tableName: 'videoChannel', | 208 | tableName: 'videoChannel', |
179 | indexes | 209 | indexes: [ |
210 | buildTrigramSearchIndex('video_channel_name_trigram', 'name'), | ||
211 | |||
212 | { | ||
213 | fields: [ 'accountId' ] | ||
214 | }, | ||
215 | { | ||
216 | fields: [ 'actorId' ] | ||
217 | } | ||
218 | ] | ||
180 | }) | 219 | }) |
181 | export class VideoChannelModel extends Model<VideoChannelModel> { | 220 | export class VideoChannelModel extends Model<VideoChannelModel> { |
182 | 221 | ||
@@ -351,10 +390,11 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
351 | } | 390 | } |
352 | 391 | ||
353 | static listByAccount (options: { | 392 | static listByAccount (options: { |
354 | accountId: number, | 393 | accountId: number |
355 | start: number, | 394 | start: number |
356 | count: number, | 395 | count: number |
357 | sort: string | 396 | sort: string |
397 | withStats?: boolean | ||
358 | }) { | 398 | }) { |
359 | const query = { | 399 | const query = { |
360 | offset: options.start, | 400 | offset: options.start, |
@@ -371,7 +411,16 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
371 | ] | 411 | ] |
372 | } | 412 | } |
373 | 413 | ||
414 | const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ] | ||
415 | |||
416 | if (options.withStats) { | ||
417 | scopes.push({ | ||
418 | method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ] | ||
419 | }) | ||
420 | } | ||
421 | |||
374 | return VideoChannelModel | 422 | return VideoChannelModel |
423 | .scope(scopes) | ||
375 | .findAndCountAll(query) | 424 | .findAndCountAll(query) |
376 | .then(({ rows, count }) => { | 425 | .then(({ rows, count }) => { |
377 | return { total: count, data: rows } | 426 | return { total: count, data: rows } |
@@ -499,6 +548,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
499 | } | 548 | } |
500 | 549 | ||
501 | toFormattedJSON (this: MChannelFormattable): VideoChannel { | 550 | toFormattedJSON (this: MChannelFormattable): VideoChannel { |
551 | const viewsPerDay = this.get('viewsPerDay') as string | ||
552 | |||
502 | const actor = this.Actor.toFormattedJSON() | 553 | const actor = this.Actor.toFormattedJSON() |
503 | const videoChannel = { | 554 | const videoChannel = { |
504 | id: this.id, | 555 | id: this.id, |
@@ -508,7 +559,16 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
508 | isLocal: this.Actor.isOwned(), | 559 | isLocal: this.Actor.isOwned(), |
509 | createdAt: this.createdAt, | 560 | createdAt: this.createdAt, |
510 | updatedAt: this.updatedAt, | 561 | updatedAt: this.updatedAt, |
511 | ownerAccount: undefined | 562 | ownerAccount: undefined, |
563 | viewsPerDay: viewsPerDay !== undefined | ||
564 | ? viewsPerDay.split(',').map(v => { | ||
565 | const o = v.split('|') | ||
566 | return { | ||
567 | date: new Date(o[0]), | ||
568 | views: +o[1] | ||
569 | } | ||
570 | }) | ||
571 | : undefined | ||
512 | } | 572 | } |
513 | 573 | ||
514 | if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() | 574 | if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() |
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index fb4d16b4d..6d60271e6 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts | |||
@@ -9,7 +9,6 @@ import { ActorModel } from '../activitypub/actor' | |||
9 | import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' | 9 | import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' |
10 | import { VideoModel } from './video' | 10 | import { VideoModel } from './video' |
11 | import { VideoChannelModel } from './video-channel' | 11 | import { VideoChannelModel } from './video-channel' |
12 | import { getServerActor } from '../../helpers/utils' | ||
13 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' | 12 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' |
14 | import { regexpCapture } from '../../helpers/regexp' | 13 | import { regexpCapture } from '../../helpers/regexp' |
15 | import { uniq } from 'lodash' | 14 | import { uniq } from 'lodash' |
@@ -27,6 +26,8 @@ import { | |||
27 | MCommentOwnerVideoReply | 26 | MCommentOwnerVideoReply |
28 | } from '../../typings/models/video' | 27 | } from '../../typings/models/video' |
29 | import { MUserAccountId } from '@server/typings/models' | 28 | import { MUserAccountId } from '@server/typings/models' |
29 | import { VideoPrivacy } from '@shared/models' | ||
30 | import { getServerActor } from '@server/models/application/application' | ||
30 | 31 | ||
31 | enum ScopeNames { | 32 | enum ScopeNames { |
32 | WITH_ACCOUNT = 'WITH_ACCOUNT', | 33 | WITH_ACCOUNT = 'WITH_ACCOUNT', |
@@ -257,10 +258,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
257 | } | 258 | } |
258 | 259 | ||
259 | static async listThreadsForApi (parameters: { | 260 | static async listThreadsForApi (parameters: { |
260 | videoId: number, | 261 | videoId: number |
261 | start: number, | 262 | start: number |
262 | count: number, | 263 | count: number |
263 | sort: string, | 264 | sort: string |
264 | user?: MUserAccountId | 265 | user?: MUserAccountId |
265 | }) { | 266 | }) { |
266 | const { videoId, start, count, sort, user } = parameters | 267 | const { videoId, start, count, sort, user } = parameters |
@@ -300,8 +301,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
300 | } | 301 | } |
301 | 302 | ||
302 | static async listThreadCommentsForApi (parameters: { | 303 | static async listThreadCommentsForApi (parameters: { |
303 | videoId: number, | 304 | videoId: number |
304 | threadId: number, | 305 | threadId: number |
305 | user?: MUserAccountId | 306 | user?: MUserAccountId |
306 | }) { | 307 | }) { |
307 | const { videoId, threadId, user } = parameters | 308 | const { videoId, threadId, user } = parameters |
@@ -314,7 +315,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
314 | order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, | 315 | order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, |
315 | where: { | 316 | where: { |
316 | videoId, | 317 | videoId, |
317 | [ Op.or ]: [ | 318 | [Op.or]: [ |
318 | { id: threadId }, | 319 | { id: threadId }, |
319 | { originCommentId: threadId } | 320 | { originCommentId: threadId } |
320 | ], | 321 | ], |
@@ -346,7 +347,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
346 | order: [ [ 'createdAt', order ] ] as Order, | 347 | order: [ [ 'createdAt', order ] ] as Order, |
347 | where: { | 348 | where: { |
348 | id: { | 349 | id: { |
349 | [ Op.in ]: Sequelize.literal('(' + | 350 | [Op.in]: Sequelize.literal('(' + |
350 | 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + | 351 | 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + |
351 | `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` + | 352 | `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` + |
352 | 'UNION ' + | 353 | 'UNION ' + |
@@ -355,7 +356,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
355 | ') ' + | 356 | ') ' + |
356 | 'SELECT id FROM children' + | 357 | 'SELECT id FROM children' + |
357 | ')'), | 358 | ')'), |
358 | [ Op.ne ]: comment.id | 359 | [Op.ne]: comment.id |
359 | } | 360 | } |
360 | }, | 361 | }, |
361 | transaction: t | 362 | transaction: t |
@@ -380,17 +381,29 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
380 | return VideoCommentModel.findAndCountAll<MComment>(query) | 381 | return VideoCommentModel.findAndCountAll<MComment>(query) |
381 | } | 382 | } |
382 | 383 | ||
383 | static listForFeed (start: number, count: number, videoId?: number): Bluebird<MCommentOwnerVideoFeed[]> { | 384 | static async listForFeed (start: number, count: number, videoId?: number): Promise<MCommentOwnerVideoFeed[]> { |
385 | const serverActor = await getServerActor() | ||
386 | |||
384 | const query = { | 387 | const query = { |
385 | order: [ [ 'createdAt', 'DESC' ] ] as Order, | 388 | order: [ [ 'createdAt', 'DESC' ] ] as Order, |
386 | offset: start, | 389 | offset: start, |
387 | limit: count, | 390 | limit: count, |
388 | where: {}, | 391 | where: { |
392 | deletedAt: null, | ||
393 | accountId: { | ||
394 | [Op.notIn]: Sequelize.literal( | ||
395 | '(' + buildBlockedAccountSQL(serverActor.Account.id) + ')' | ||
396 | ) | ||
397 | } | ||
398 | }, | ||
389 | include: [ | 399 | include: [ |
390 | { | 400 | { |
391 | attributes: [ 'name', 'uuid' ], | 401 | attributes: [ 'name', 'uuid' ], |
392 | model: VideoModel.unscoped(), | 402 | model: VideoModel.unscoped(), |
393 | required: true | 403 | required: true, |
404 | where: { | ||
405 | privacy: VideoPrivacy.PUBLIC | ||
406 | } | ||
394 | } | 407 | } |
395 | ] | 408 | ] |
396 | } | 409 | } |
@@ -461,7 +474,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
461 | } | 474 | } |
462 | 475 | ||
463 | isDeleted () { | 476 | isDeleted () { |
464 | return null !== this.deletedAt | 477 | return this.deletedAt !== null |
465 | } | 478 | } |
466 | 479 | ||
467 | extractMentions () { | 480 | extractMentions () { |
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index e08999385..201f0c0f1 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts | |||
@@ -10,7 +10,9 @@ import { | |||
10 | Is, | 10 | Is, |
11 | Model, | 11 | Model, |
12 | Table, | 12 | Table, |
13 | UpdatedAt | 13 | UpdatedAt, |
14 | Scopes, | ||
15 | DefaultScope | ||
14 | } from 'sequelize-typescript' | 16 | } from 'sequelize-typescript' |
15 | import { | 17 | import { |
16 | isVideoFileExtnameValid, | 18 | isVideoFileExtnameValid, |
@@ -28,7 +30,33 @@ import { MIMETYPES, MEMOIZE_LENGTH, MEMOIZE_TTL } from '../../initializers/const | |||
28 | import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../typings/models/video/video-file' | 30 | import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../typings/models/video/video-file' |
29 | import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models' | 31 | import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models' |
30 | import * as memoizee from 'memoizee' | 32 | import * as memoizee from 'memoizee' |
33 | import validator from 'validator' | ||
31 | 34 | ||
35 | export enum ScopeNames { | ||
36 | WITH_VIDEO = 'WITH_VIDEO', | ||
37 | WITH_METADATA = 'WITH_METADATA' | ||
38 | } | ||
39 | |||
40 | @DefaultScope(() => ({ | ||
41 | attributes: { | ||
42 | exclude: [ 'metadata' ] | ||
43 | } | ||
44 | })) | ||
45 | @Scopes(() => ({ | ||
46 | [ScopeNames.WITH_VIDEO]: { | ||
47 | include: [ | ||
48 | { | ||
49 | model: VideoModel.unscoped(), | ||
50 | required: true | ||
51 | } | ||
52 | ] | ||
53 | }, | ||
54 | [ScopeNames.WITH_METADATA]: { | ||
55 | attributes: { | ||
56 | include: [ 'metadata' ] | ||
57 | } | ||
58 | } | ||
59 | })) | ||
32 | @Table({ | 60 | @Table({ |
33 | tableName: 'videoFile', | 61 | tableName: 'videoFile', |
34 | indexes: [ | 62 | indexes: [ |
@@ -106,6 +134,14 @@ export class VideoFileModel extends Model<VideoFileModel> { | |||
106 | @Column | 134 | @Column |
107 | fps: number | 135 | fps: number |
108 | 136 | ||
137 | @AllowNull(true) | ||
138 | @Column(DataType.JSONB) | ||
139 | metadata: any | ||
140 | |||
141 | @AllowNull(true) | ||
142 | @Column | ||
143 | metadataUrl: string | ||
144 | |||
109 | @ForeignKey(() => VideoModel) | 145 | @ForeignKey(() => VideoModel) |
110 | @Column | 146 | @Column |
111 | videoId: number | 147 | videoId: number |
@@ -157,17 +193,56 @@ export class VideoFileModel extends Model<VideoFileModel> { | |||
157 | .then(results => results.length === 1) | 193 | .then(results => results.length === 1) |
158 | } | 194 | } |
159 | 195 | ||
196 | static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { | ||
197 | const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID) | ||
198 | |||
199 | return !!videoFile | ||
200 | } | ||
201 | |||
202 | static loadWithMetadata (id: number) { | ||
203 | return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id) | ||
204 | } | ||
205 | |||
160 | static loadWithVideo (id: number) { | 206 | static loadWithVideo (id: number) { |
207 | return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id) | ||
208 | } | ||
209 | |||
210 | static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) { | ||
211 | const whereVideo = validator.isUUID(videoIdOrUUID + '') | ||
212 | ? { uuid: videoIdOrUUID } | ||
213 | : { id: videoIdOrUUID } | ||
214 | |||
161 | const options = { | 215 | const options = { |
216 | where: { | ||
217 | id | ||
218 | }, | ||
162 | include: [ | 219 | include: [ |
163 | { | 220 | { |
164 | model: VideoModel.unscoped(), | 221 | model: VideoModel.unscoped(), |
165 | required: true | 222 | required: false, |
223 | where: whereVideo | ||
224 | }, | ||
225 | { | ||
226 | model: VideoStreamingPlaylistModel.unscoped(), | ||
227 | required: false, | ||
228 | include: [ | ||
229 | { | ||
230 | model: VideoModel.unscoped(), | ||
231 | required: true, | ||
232 | where: whereVideo | ||
233 | } | ||
234 | ] | ||
166 | } | 235 | } |
167 | ] | 236 | ] |
168 | } | 237 | } |
169 | 238 | ||
170 | return VideoFileModel.findByPk(id, options) | 239 | return VideoFileModel.findOne(options) |
240 | .then(file => { | ||
241 | // We used `required: false` so check we have at least a video or a streaming playlist | ||
242 | if (!file.Video && !file.VideoStreamingPlaylist) return null | ||
243 | |||
244 | return file | ||
245 | }) | ||
171 | } | 246 | } |
172 | 247 | ||
173 | static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) { | 248 | static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) { |
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index 67395e5c0..d71a3a5db 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts | |||
@@ -8,7 +8,7 @@ import { | |||
8 | getVideoDislikesActivityPubUrl, | 8 | getVideoDislikesActivityPubUrl, |
9 | getVideoLikesActivityPubUrl, | 9 | getVideoLikesActivityPubUrl, |
10 | getVideoSharesActivityPubUrl | 10 | getVideoSharesActivityPubUrl |
11 | } from '../../lib/activitypub' | 11 | } from '../../lib/activitypub/url' |
12 | import { isArray } from '../../helpers/custom-validators/misc' | 12 | import { isArray } from '../../helpers/custom-validators/misc' |
13 | import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' | 13 | import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' |
14 | import { | 14 | import { |
@@ -23,16 +23,18 @@ import { | |||
23 | import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file' | 23 | import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file' |
24 | import { VideoFile } from '@shared/models/videos/video-file.model' | 24 | import { VideoFile } from '@shared/models/videos/video-file.model' |
25 | import { generateMagnetUri } from '@server/helpers/webtorrent' | 25 | import { generateMagnetUri } from '@server/helpers/webtorrent' |
26 | import { extractVideo } from '@server/helpers/video' | ||
26 | 27 | ||
27 | export type VideoFormattingJSONOptions = { | 28 | export type VideoFormattingJSONOptions = { |
28 | completeDescription?: boolean | 29 | completeDescription?: boolean |
29 | additionalAttributes: { | 30 | additionalAttributes: { |
30 | state?: boolean, | 31 | state?: boolean |
31 | waitTranscoding?: boolean, | 32 | waitTranscoding?: boolean |
32 | scheduledUpdate?: boolean, | 33 | scheduledUpdate?: boolean |
33 | blacklistInfo?: boolean | 34 | blacklistInfo?: boolean |
34 | } | 35 | } |
35 | } | 36 | } |
37 | |||
36 | function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { | 38 | function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { |
37 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined | 39 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined |
38 | 40 | ||
@@ -179,14 +181,14 @@ function videoFilesModelToFormattedJSON ( | |||
179 | baseUrlWs: string, | 181 | baseUrlWs: string, |
180 | videoFiles: MVideoFileRedundanciesOpt[] | 182 | videoFiles: MVideoFileRedundanciesOpt[] |
181 | ): VideoFile[] { | 183 | ): VideoFile[] { |
184 | const video = extractVideo(model) | ||
185 | |||
182 | return videoFiles | 186 | return videoFiles |
183 | .map(videoFile => { | 187 | .map(videoFile => { |
184 | let resolutionLabel = videoFile.resolution + 'p' | ||
185 | |||
186 | return { | 188 | return { |
187 | resolution: { | 189 | resolution: { |
188 | id: videoFile.resolution, | 190 | id: videoFile.resolution, |
189 | label: resolutionLabel | 191 | label: videoFile.resolution + 'p' |
190 | }, | 192 | }, |
191 | magnetUri: generateMagnetUri(model, videoFile, baseUrlHttp, baseUrlWs), | 193 | magnetUri: generateMagnetUri(model, videoFile, baseUrlHttp, baseUrlWs), |
192 | size: videoFile.size, | 194 | size: videoFile.size, |
@@ -194,7 +196,8 @@ function videoFilesModelToFormattedJSON ( | |||
194 | torrentUrl: model.getTorrentUrl(videoFile, baseUrlHttp), | 196 | torrentUrl: model.getTorrentUrl(videoFile, baseUrlHttp), |
195 | torrentDownloadUrl: model.getTorrentDownloadUrl(videoFile, baseUrlHttp), | 197 | torrentDownloadUrl: model.getTorrentDownloadUrl(videoFile, baseUrlHttp), |
196 | fileUrl: model.getVideoFileUrl(videoFile, baseUrlHttp), | 198 | fileUrl: model.getVideoFileUrl(videoFile, baseUrlHttp), |
197 | fileDownloadUrl: model.getVideoFileDownloadUrl(videoFile, baseUrlHttp) | 199 | fileDownloadUrl: model.getVideoFileDownloadUrl(videoFile, baseUrlHttp), |
200 | metadataUrl: video.getVideoFileMetadataUrl(videoFile, baseUrlHttp) | ||
198 | } as VideoFile | 201 | } as VideoFile |
199 | }) | 202 | }) |
200 | .sort((a, b) => { | 203 | .sort((a, b) => { |
@@ -214,7 +217,7 @@ function addVideoFilesInAPAcc ( | |||
214 | for (const file of files) { | 217 | for (const file of files) { |
215 | acc.push({ | 218 | acc.push({ |
216 | type: 'Link', | 219 | type: 'Link', |
217 | mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any, | 220 | mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any, |
218 | href: model.getVideoFileUrl(file, baseUrlHttp), | 221 | href: model.getVideoFileUrl(file, baseUrlHttp), |
219 | height: file.resolution, | 222 | height: file.resolution, |
220 | size: file.size, | 223 | size: file.size, |
@@ -223,6 +226,15 @@ function addVideoFilesInAPAcc ( | |||
223 | 226 | ||
224 | acc.push({ | 227 | acc.push({ |
225 | type: 'Link', | 228 | type: 'Link', |
229 | rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ], | ||
230 | mediaType: 'application/json' as 'application/json', | ||
231 | href: extractVideo(model).getVideoFileMetadataUrl(file, baseUrlHttp), | ||
232 | height: file.resolution, | ||
233 | fps: file.fps | ||
234 | }) | ||
235 | |||
236 | acc.push({ | ||
237 | type: 'Link', | ||
226 | mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', | 238 | mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', |
227 | href: model.getTorrentUrl(file, baseUrlHttp), | 239 | href: model.getTorrentUrl(file, baseUrlHttp), |
228 | height: file.resolution | 240 | height: file.resolution |
@@ -282,10 +294,8 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { | |||
282 | addVideoFilesInAPAcc(url, video, baseUrlHttp, baseUrlWs, video.VideoFiles || []) | 294 | addVideoFilesInAPAcc(url, video, baseUrlHttp, baseUrlWs, video.VideoFiles || []) |
283 | 295 | ||
284 | for (const playlist of (video.VideoStreamingPlaylists || [])) { | 296 | for (const playlist of (video.VideoStreamingPlaylists || [])) { |
285 | let tag: ActivityTagObject[] | 297 | const tag = playlist.p2pMediaLoaderInfohashes |
286 | 298 | .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) as ActivityTagObject[] | |
287 | tag = playlist.p2pMediaLoaderInfohashes | ||
288 | .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) | ||
289 | tag.push({ | 299 | tag.push({ |
290 | type: 'Link', | 300 | type: 'Link', |
291 | name: 'sha256', | 301 | name: 'sha256', |
@@ -308,10 +318,14 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { | |||
308 | for (const caption of video.VideoCaptions) { | 318 | for (const caption of video.VideoCaptions) { |
309 | subtitleLanguage.push({ | 319 | subtitleLanguage.push({ |
310 | identifier: caption.language, | 320 | identifier: caption.language, |
311 | name: VideoCaptionModel.getLanguageLabel(caption.language) | 321 | name: VideoCaptionModel.getLanguageLabel(caption.language), |
322 | url: caption.getFileUrl(video) | ||
312 | }) | 323 | }) |
313 | } | 324 | } |
314 | 325 | ||
326 | // FIXME: remove and uncomment in PT 2.3 | ||
327 | // Breaks compatibility with PT <= 2.1 | ||
328 | // const icons = [ video.getMiniature(), video.getPreview() ] | ||
315 | const miniature = video.getMiniature() | 329 | const miniature = video.getMiniature() |
316 | 330 | ||
317 | return { | 331 | return { |
@@ -339,11 +353,18 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { | |||
339 | subtitleLanguage, | 353 | subtitleLanguage, |
340 | icon: { | 354 | icon: { |
341 | type: 'Image', | 355 | type: 'Image', |
342 | url: miniature.getFileUrl(video.isOwned()), | 356 | url: miniature.getFileUrl(video), |
343 | mediaType: 'image/jpeg', | 357 | mediaType: 'image/jpeg', |
344 | width: miniature.width, | 358 | width: miniature.width, |
345 | height: miniature.height | 359 | height: miniature.height |
346 | }, | 360 | } as any, |
361 | // icon: icons.map(i => ({ | ||
362 | // type: 'Image', | ||
363 | // url: i.getFileUrl(video), | ||
364 | // mediaType: 'image/jpeg', | ||
365 | // width: i.width, | ||
366 | // height: i.height | ||
367 | // })), | ||
347 | url, | 368 | url, |
348 | likes: getVideoLikesActivityPubUrl(video), | 369 | likes: getVideoLikesActivityPubUrl(video), |
349 | dislikes: getVideoDislikesActivityPubUrl(video), | 370 | dislikes: getVideoDislikesActivityPubUrl(video), |
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts index af5314ce9..fbe0ee0a7 100644 --- a/server/models/video/video-import.ts +++ b/server/models/video/video-import.ts | |||
@@ -129,6 +129,7 @@ export class VideoImportModel extends Model<VideoImportModel> { | |||
129 | distinct: true, | 129 | distinct: true, |
130 | include: [ | 130 | include: [ |
131 | { | 131 | { |
132 | attributes: [ 'id' ], | ||
132 | model: UserModel.unscoped(), // FIXME: Without this, sequelize try to COUNT(DISTINCT(*)) which is an invalid SQL query | 133 | model: UserModel.unscoped(), // FIXME: Without this, sequelize try to COUNT(DISTINCT(*)) which is an invalid SQL query |
133 | required: true | 134 | required: true |
134 | } | 135 | } |
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts index f2d71357f..9ea73e82e 100644 --- a/server/models/video/video-playlist-element.ts +++ b/server/models/video/video-playlist-element.ts | |||
@@ -120,10 +120,10 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> | |||
120 | } | 120 | } |
121 | 121 | ||
122 | static listForApi (options: { | 122 | static listForApi (options: { |
123 | start: number, | 123 | start: number |
124 | count: number, | 124 | count: number |
125 | videoPlaylistId: number, | 125 | videoPlaylistId: number |
126 | serverAccount: AccountModel, | 126 | serverAccount: AccountModel |
127 | user?: MUserAccountId | 127 | user?: MUserAccountId |
128 | }) { | 128 | }) { |
129 | const accountIds = [ options.serverAccount.id ] | 129 | const accountIds = [ options.serverAccount.id ] |
@@ -309,7 +309,10 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> | |||
309 | // Owned video, don't filter it | 309 | // Owned video, don't filter it |
310 | if (accountId && video.VideoChannel.Account.id === accountId) return VideoPlaylistElementType.REGULAR | 310 | if (accountId && video.VideoChannel.Account.id === accountId) return VideoPlaylistElementType.REGULAR |
311 | 311 | ||
312 | if (video.privacy === VideoPrivacy.PRIVATE) return VideoPlaylistElementType.PRIVATE | 312 | // Internal video? |
313 | if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR | ||
314 | |||
315 | if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE | ||
313 | 316 | ||
314 | if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE | 317 | if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE |
315 | if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE | 318 | if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE |
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index bcdda36e5..b9b95e067 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts | |||
@@ -68,12 +68,12 @@ type AvailableForListOptions = { | |||
68 | type?: VideoPlaylistType | 68 | type?: VideoPlaylistType |
69 | accountId?: number | 69 | accountId?: number |
70 | videoChannelId?: number | 70 | videoChannelId?: number |
71 | listMyPlaylists?: boolean, | 71 | listMyPlaylists?: boolean |
72 | search?: string | 72 | search?: string |
73 | } | 73 | } |
74 | 74 | ||
75 | @Scopes(() => ({ | 75 | @Scopes(() => ({ |
76 | [ ScopeNames.WITH_THUMBNAIL ]: { | 76 | [ScopeNames.WITH_THUMBNAIL]: { |
77 | include: [ | 77 | include: [ |
78 | { | 78 | { |
79 | model: ThumbnailModel, | 79 | model: ThumbnailModel, |
@@ -81,7 +81,7 @@ type AvailableForListOptions = { | |||
81 | } | 81 | } |
82 | ] | 82 | ] |
83 | }, | 83 | }, |
84 | [ ScopeNames.WITH_VIDEOS_LENGTH ]: { | 84 | [ScopeNames.WITH_VIDEOS_LENGTH]: { |
85 | attributes: { | 85 | attributes: { |
86 | include: [ | 86 | include: [ |
87 | [ | 87 | [ |
@@ -91,7 +91,7 @@ type AvailableForListOptions = { | |||
91 | ] | 91 | ] |
92 | } | 92 | } |
93 | } as FindOptions, | 93 | } as FindOptions, |
94 | [ ScopeNames.WITH_ACCOUNT ]: { | 94 | [ScopeNames.WITH_ACCOUNT]: { |
95 | include: [ | 95 | include: [ |
96 | { | 96 | { |
97 | model: AccountModel, | 97 | model: AccountModel, |
@@ -99,7 +99,7 @@ type AvailableForListOptions = { | |||
99 | } | 99 | } |
100 | ] | 100 | ] |
101 | }, | 101 | }, |
102 | [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: { | 102 | [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: { |
103 | include: [ | 103 | include: [ |
104 | { | 104 | { |
105 | model: AccountModel.scope(AccountScopeNames.SUMMARY), | 105 | model: AccountModel.scope(AccountScopeNames.SUMMARY), |
@@ -111,7 +111,7 @@ type AvailableForListOptions = { | |||
111 | } | 111 | } |
112 | ] | 112 | ] |
113 | }, | 113 | }, |
114 | [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: { | 114 | [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { |
115 | include: [ | 115 | include: [ |
116 | { | 116 | { |
117 | model: AccountModel, | 117 | model: AccountModel, |
@@ -123,7 +123,7 @@ type AvailableForListOptions = { | |||
123 | } | 123 | } |
124 | ] | 124 | ] |
125 | }, | 125 | }, |
126 | [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => { | 126 | [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { |
127 | 127 | ||
128 | let whereActor: WhereOptions = {} | 128 | let whereActor: WhereOptions = {} |
129 | 129 | ||
@@ -138,13 +138,13 @@ type AvailableForListOptions = { | |||
138 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) | 138 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) |
139 | 139 | ||
140 | whereActor = { | 140 | whereActor = { |
141 | [ Op.or ]: [ | 141 | [Op.or]: [ |
142 | { | 142 | { |
143 | serverId: null | 143 | serverId: null |
144 | }, | 144 | }, |
145 | { | 145 | { |
146 | serverId: { | 146 | serverId: { |
147 | [ Op.in ]: literal(inQueryInstanceFollow) | 147 | [Op.in]: literal(inQueryInstanceFollow) |
148 | } | 148 | } |
149 | } | 149 | } |
150 | ] | 150 | ] |
@@ -172,7 +172,7 @@ type AvailableForListOptions = { | |||
172 | if (options.search) { | 172 | if (options.search) { |
173 | whereAnd.push({ | 173 | whereAnd.push({ |
174 | name: { | 174 | name: { |
175 | [ Op.iLike ]: '%' + options.search + '%' | 175 | [Op.iLike]: '%' + options.search + '%' |
176 | } | 176 | } |
177 | }) | 177 | }) |
178 | } | 178 | } |
@@ -230,7 +230,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
230 | 230 | ||
231 | @AllowNull(true) | 231 | @AllowNull(true) |
232 | @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true)) | 232 | @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true)) |
233 | @Column | 233 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.DESCRIPTION.max)) |
234 | description: string | 234 | description: string |
235 | 235 | ||
236 | @AllowNull(false) | 236 | @AllowNull(false) |
@@ -299,13 +299,13 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
299 | 299 | ||
300 | static listForApi (options: { | 300 | static listForApi (options: { |
301 | followerActorId: number | 301 | followerActorId: number |
302 | start: number, | 302 | start: number |
303 | count: number, | 303 | count: number |
304 | sort: string, | 304 | sort: string |
305 | type?: VideoPlaylistType, | 305 | type?: VideoPlaylistType |
306 | accountId?: number, | 306 | accountId?: number |
307 | videoChannelId?: number, | 307 | videoChannelId?: number |
308 | listMyPlaylists?: boolean, | 308 | listMyPlaylists?: boolean |
309 | search?: string | 309 | search?: string |
310 | }) { | 310 | }) { |
311 | const query = { | 311 | const query = { |
@@ -369,7 +369,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
369 | model: VideoPlaylistElementModel.unscoped(), | 369 | model: VideoPlaylistElementModel.unscoped(), |
370 | where: { | 370 | where: { |
371 | videoId: { | 371 | videoId: { |
372 | [Op.in]: videoIds // FIXME: sequelize ANY seems broken | 372 | [Op.in]: videoIds |
373 | } | 373 | } |
374 | }, | 374 | }, |
375 | required: true | 375 | required: true |
@@ -522,7 +522,9 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
522 | updatedAt: this.updatedAt, | 522 | updatedAt: this.updatedAt, |
523 | 523 | ||
524 | ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), | 524 | ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), |
525 | videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null | 525 | videoChannel: this.VideoChannel |
526 | ? this.VideoChannel.toFormattedSummaryJSON() | ||
527 | : null | ||
526 | } | 528 | } |
527 | } | 529 | } |
528 | 530 | ||
diff --git a/server/models/video/video-query-builder.ts b/server/models/video/video-query-builder.ts new file mode 100644 index 000000000..455f9f30f --- /dev/null +++ b/server/models/video/video-query-builder.ts | |||
@@ -0,0 +1,503 @@ | |||
1 | import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models' | ||
2 | import { buildDirectionAndField, createSafeIn } from '@server/models/utils' | ||
3 | import { Model } from 'sequelize-typescript' | ||
4 | import { MUserAccountId, MUserId } from '@server/typings/models' | ||
5 | import validator from 'validator' | ||
6 | import { exists } from '@server/helpers/custom-validators/misc' | ||
7 | |||
8 | export type BuildVideosQueryOptions = { | ||
9 | attributes?: string[] | ||
10 | |||
11 | serverAccountId: number | ||
12 | followerActorId: number | ||
13 | includeLocalVideos: boolean | ||
14 | |||
15 | count: number | ||
16 | start: number | ||
17 | sort: string | ||
18 | |||
19 | filter?: VideoFilter | ||
20 | categoryOneOf?: number[] | ||
21 | nsfw?: boolean | ||
22 | licenceOneOf?: number[] | ||
23 | languageOneOf?: string[] | ||
24 | tagsOneOf?: string[] | ||
25 | tagsAllOf?: string[] | ||
26 | |||
27 | withFiles?: boolean | ||
28 | |||
29 | accountId?: number | ||
30 | videoChannelId?: number | ||
31 | |||
32 | videoPlaylistId?: number | ||
33 | |||
34 | trendingDays?: number | ||
35 | user?: MUserAccountId | ||
36 | historyOfUser?: MUserId | ||
37 | |||
38 | startDate?: string // ISO 8601 | ||
39 | endDate?: string // ISO 8601 | ||
40 | originallyPublishedStartDate?: string | ||
41 | originallyPublishedEndDate?: string | ||
42 | |||
43 | durationMin?: number // seconds | ||
44 | durationMax?: number // seconds | ||
45 | |||
46 | search?: string | ||
47 | |||
48 | isCount?: boolean | ||
49 | |||
50 | group?: string | ||
51 | having?: string | ||
52 | } | ||
53 | |||
54 | function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) { | ||
55 | const and: string[] = [] | ||
56 | const joins: string[] = [] | ||
57 | const replacements: any = {} | ||
58 | const cte: string[] = [] | ||
59 | |||
60 | let attributes: string[] = options.attributes || [ '"video"."id"' ] | ||
61 | let group = options.group || '' | ||
62 | const having = options.having || '' | ||
63 | |||
64 | joins.push( | ||
65 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"' + | ||
66 | 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"' + | ||
67 | 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"' | ||
68 | ) | ||
69 | |||
70 | and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")') | ||
71 | |||
72 | if (options.serverAccountId) { | ||
73 | const blockerIds = [ options.serverAccountId ] | ||
74 | if (options.user) blockerIds.push(options.user.Account.id) | ||
75 | |||
76 | const inClause = createSafeIn(model, blockerIds) | ||
77 | |||
78 | and.push( | ||
79 | 'NOT EXISTS (' + | ||
80 | ' SELECT 1 FROM "accountBlocklist" ' + | ||
81 | ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' + | ||
82 | ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' + | ||
83 | ')' + | ||
84 | 'AND NOT EXISTS (' + | ||
85 | ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' + | ||
86 | ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' + | ||
87 | ')' | ||
88 | ) | ||
89 | } | ||
90 | |||
91 | // Only list public/published videos | ||
92 | if (!options.filter || options.filter !== 'all-local') { | ||
93 | and.push( | ||
94 | `("video"."state" = ${VideoState.PUBLISHED} OR ` + | ||
95 | `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))` | ||
96 | ) | ||
97 | |||
98 | if (options.user) { | ||
99 | and.push( | ||
100 | `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})` | ||
101 | ) | ||
102 | } else { // Or only public videos | ||
103 | and.push( | ||
104 | `"video"."privacy" = ${VideoPrivacy.PUBLIC}` | ||
105 | ) | ||
106 | } | ||
107 | } | ||
108 | |||
109 | if (options.videoPlaylistId) { | ||
110 | joins.push( | ||
111 | 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' + | ||
112 | 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId' | ||
113 | ) | ||
114 | |||
115 | replacements.videoPlaylistId = options.videoPlaylistId | ||
116 | } | ||
117 | |||
118 | if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) { | ||
119 | and.push('"video"."remote" IS FALSE') | ||
120 | } | ||
121 | |||
122 | if (options.accountId) { | ||
123 | and.push('"account"."id" = :accountId') | ||
124 | replacements.accountId = options.accountId | ||
125 | } | ||
126 | |||
127 | if (options.videoChannelId) { | ||
128 | and.push('"videoChannel"."id" = :videoChannelId') | ||
129 | replacements.videoChannelId = options.videoChannelId | ||
130 | } | ||
131 | |||
132 | if (options.followerActorId) { | ||
133 | let query = | ||
134 | '(' + | ||
135 | ' EXISTS (' + | ||
136 | ' SELECT 1 FROM "videoShare" ' + | ||
137 | ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' + | ||
138 | ' AND "actorFollowShare"."actorId" = :followerActorId WHERE "videoShare"."videoId" = "video"."id"' + | ||
139 | ' )' + | ||
140 | ' OR' + | ||
141 | ' EXISTS (' + | ||
142 | ' SELECT 1 from "actorFollow" ' + | ||
143 | ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId' + | ||
144 | ' )' | ||
145 | |||
146 | if (options.includeLocalVideos) { | ||
147 | query += ' OR "video"."remote" IS FALSE' | ||
148 | } | ||
149 | |||
150 | query += ')' | ||
151 | |||
152 | and.push(query) | ||
153 | replacements.followerActorId = options.followerActorId | ||
154 | } | ||
155 | |||
156 | if (options.withFiles === true) { | ||
157 | and.push('EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")') | ||
158 | } | ||
159 | |||
160 | if (options.tagsOneOf) { | ||
161 | const tagsOneOfLower = options.tagsOneOf.map(t => t.toLowerCase()) | ||
162 | |||
163 | and.push( | ||
164 | 'EXISTS (' + | ||
165 | ' SELECT 1 FROM "videoTag" ' + | ||
166 | ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
167 | ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsOneOfLower) + ') ' + | ||
168 | ' AND "video"."id" = "videoTag"."videoId"' + | ||
169 | ')' | ||
170 | ) | ||
171 | } | ||
172 | |||
173 | if (options.tagsAllOf) { | ||
174 | const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase()) | ||
175 | |||
176 | and.push( | ||
177 | 'EXISTS (' + | ||
178 | ' SELECT 1 FROM "videoTag" ' + | ||
179 | ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
180 | ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsAllOfLower) + ') ' + | ||
181 | ' AND "video"."id" = "videoTag"."videoId" ' + | ||
182 | ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length + | ||
183 | ')' | ||
184 | ) | ||
185 | } | ||
186 | |||
187 | if (options.nsfw === true) { | ||
188 | and.push('"video"."nsfw" IS TRUE') | ||
189 | } | ||
190 | |||
191 | if (options.nsfw === false) { | ||
192 | and.push('"video"."nsfw" IS FALSE') | ||
193 | } | ||
194 | |||
195 | if (options.categoryOneOf) { | ||
196 | and.push('"video"."category" IN (:categoryOneOf)') | ||
197 | replacements.categoryOneOf = options.categoryOneOf | ||
198 | } | ||
199 | |||
200 | if (options.licenceOneOf) { | ||
201 | and.push('"video"."licence" IN (:licenceOneOf)') | ||
202 | replacements.licenceOneOf = options.licenceOneOf | ||
203 | } | ||
204 | |||
205 | if (options.languageOneOf) { | ||
206 | const languages = options.languageOneOf.filter(l => l && l !== '_unknown') | ||
207 | const languagesQueryParts: string[] = [] | ||
208 | |||
209 | if (languages.length !== 0) { | ||
210 | languagesQueryParts.push('"video"."language" IN (:languageOneOf)') | ||
211 | replacements.languageOneOf = languages | ||
212 | |||
213 | languagesQueryParts.push( | ||
214 | 'EXISTS (' + | ||
215 | ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' + | ||
216 | ' IN (' + createSafeIn(model, languages) + ') AND ' + | ||
217 | ' "videoCaption"."videoId" = "video"."id"' + | ||
218 | ')' | ||
219 | ) | ||
220 | } | ||
221 | |||
222 | if (options.languageOneOf.includes('_unknown')) { | ||
223 | languagesQueryParts.push('"video"."language" IS NULL') | ||
224 | } | ||
225 | |||
226 | if (languagesQueryParts.length !== 0) { | ||
227 | and.push('(' + languagesQueryParts.join(' OR ') + ')') | ||
228 | } | ||
229 | } | ||
230 | |||
231 | // We don't exclude results in this if so if we do a count we don't need to add this complex clauses | ||
232 | if (options.trendingDays && options.isCount !== true) { | ||
233 | const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays) | ||
234 | |||
235 | joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate') | ||
236 | replacements.viewsGteDate = viewsGteDate | ||
237 | |||
238 | attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "videoViewsSum"') | ||
239 | |||
240 | group = 'GROUP BY "video"."id"' | ||
241 | } | ||
242 | |||
243 | if (options.historyOfUser) { | ||
244 | joins.push('INNER JOIN "userVideoHistory" on "video"."id" = "userVideoHistory"."videoId"') | ||
245 | |||
246 | and.push('"userVideoHistory"."userId" = :historyOfUser') | ||
247 | replacements.historyOfUser = options.historyOfUser.id | ||
248 | } | ||
249 | |||
250 | if (options.startDate) { | ||
251 | and.push('"video"."publishedAt" >= :startDate') | ||
252 | replacements.startDate = options.startDate | ||
253 | } | ||
254 | |||
255 | if (options.endDate) { | ||
256 | and.push('"video"."publishedAt" <= :endDate') | ||
257 | replacements.endDate = options.endDate | ||
258 | } | ||
259 | |||
260 | if (options.originallyPublishedStartDate) { | ||
261 | and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate') | ||
262 | replacements.originallyPublishedStartDate = options.originallyPublishedStartDate | ||
263 | } | ||
264 | |||
265 | if (options.originallyPublishedEndDate) { | ||
266 | and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate') | ||
267 | replacements.originallyPublishedEndDate = options.originallyPublishedEndDate | ||
268 | } | ||
269 | |||
270 | if (options.durationMin) { | ||
271 | and.push('"video"."duration" >= :durationMin') | ||
272 | replacements.durationMin = options.durationMin | ||
273 | } | ||
274 | |||
275 | if (options.durationMax) { | ||
276 | and.push('"video"."duration" <= :durationMax') | ||
277 | replacements.durationMax = options.durationMax | ||
278 | } | ||
279 | |||
280 | if (options.search) { | ||
281 | const escapedSearch = model.sequelize.escape(options.search) | ||
282 | const escapedLikeSearch = model.sequelize.escape('%' + options.search + '%') | ||
283 | |||
284 | cte.push( | ||
285 | '"trigramSearch" AS (' + | ||
286 | ' SELECT "video"."id", ' + | ||
287 | ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` + | ||
288 | ' FROM "video" ' + | ||
289 | ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' + | ||
290 | ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + | ||
291 | ')' | ||
292 | ) | ||
293 | |||
294 | joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"') | ||
295 | |||
296 | let base = '(' + | ||
297 | ' "trigramSearch"."id" IS NOT NULL OR ' + | ||
298 | ' EXISTS (' + | ||
299 | ' SELECT 1 FROM "videoTag" ' + | ||
300 | ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
301 | ` WHERE lower("tag"."name") = ${escapedSearch} ` + | ||
302 | ' AND "video"."id" = "videoTag"."videoId"' + | ||
303 | ' )' | ||
304 | |||
305 | if (validator.isUUID(options.search)) { | ||
306 | base += ` OR "video"."uuid" = ${escapedSearch}` | ||
307 | } | ||
308 | |||
309 | base += ')' | ||
310 | and.push(base) | ||
311 | |||
312 | attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`) | ||
313 | } else { | ||
314 | attributes.push('0 as similarity') | ||
315 | } | ||
316 | |||
317 | if (options.isCount === true) attributes = [ 'COUNT(*) as "total"' ] | ||
318 | |||
319 | let suffix = '' | ||
320 | let order = '' | ||
321 | if (options.isCount !== true) { | ||
322 | |||
323 | if (exists(options.sort)) { | ||
324 | if (options.sort === '-originallyPublishedAt' || options.sort === 'originallyPublishedAt') { | ||
325 | attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"') | ||
326 | } | ||
327 | |||
328 | order = buildOrder(model, options.sort) | ||
329 | suffix += `${order} ` | ||
330 | } | ||
331 | |||
332 | if (exists(options.count)) { | ||
333 | const count = parseInt(options.count + '', 10) | ||
334 | suffix += `LIMIT ${count} ` | ||
335 | } | ||
336 | |||
337 | if (exists(options.start)) { | ||
338 | const start = parseInt(options.start + '', 10) | ||
339 | suffix += `OFFSET ${start} ` | ||
340 | } | ||
341 | } | ||
342 | |||
343 | const cteString = cte.length !== 0 | ||
344 | ? `WITH ${cte.join(', ')} ` | ||
345 | : '' | ||
346 | |||
347 | const query = cteString + | ||
348 | 'SELECT ' + attributes.join(', ') + ' ' + | ||
349 | 'FROM "video" ' + joins.join(' ') + ' ' + | ||
350 | 'WHERE ' + and.join(' AND ') + ' ' + | ||
351 | group + ' ' + | ||
352 | having + ' ' + | ||
353 | suffix | ||
354 | |||
355 | return { query, replacements, order } | ||
356 | } | ||
357 | |||
358 | function buildOrder (model: typeof Model, value: string) { | ||
359 | const { direction, field } = buildDirectionAndField(value) | ||
360 | if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field) | ||
361 | |||
362 | if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' | ||
363 | |||
364 | if (field.toLowerCase() === 'trending') { // Sort by aggregation | ||
365 | return `ORDER BY "videoViewsSum" ${direction}, "video"."views" ${direction}` | ||
366 | } | ||
367 | |||
368 | let firstSort: string | ||
369 | |||
370 | if (field.toLowerCase() === 'match') { // Search | ||
371 | firstSort = '"similarity"' | ||
372 | } else if (field === 'originallyPublishedAt') { | ||
373 | firstSort = '"publishedAtForOrder"' | ||
374 | } else if (field.includes('.')) { | ||
375 | firstSort = field | ||
376 | } else { | ||
377 | firstSort = `"video"."${field}"` | ||
378 | } | ||
379 | |||
380 | return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC` | ||
381 | } | ||
382 | |||
383 | function wrapForAPIResults (baseQuery: string, replacements: any, options: BuildVideosQueryOptions, order: string) { | ||
384 | const attributes = { | ||
385 | '"video".*': '', | ||
386 | '"VideoChannel"."id"': '"VideoChannel.id"', | ||
387 | '"VideoChannel"."name"': '"VideoChannel.name"', | ||
388 | '"VideoChannel"."description"': '"VideoChannel.description"', | ||
389 | '"VideoChannel"."actorId"': '"VideoChannel.actorId"', | ||
390 | '"VideoChannel->Actor"."id"': '"VideoChannel.Actor.id"', | ||
391 | '"VideoChannel->Actor"."preferredUsername"': '"VideoChannel.Actor.preferredUsername"', | ||
392 | '"VideoChannel->Actor"."url"': '"VideoChannel.Actor.url"', | ||
393 | '"VideoChannel->Actor"."serverId"': '"VideoChannel.Actor.serverId"', | ||
394 | '"VideoChannel->Actor"."avatarId"': '"VideoChannel.Actor.avatarId"', | ||
395 | '"VideoChannel->Account"."id"': '"VideoChannel.Account.id"', | ||
396 | '"VideoChannel->Account"."name"': '"VideoChannel.Account.name"', | ||
397 | '"VideoChannel->Account->Actor"."id"': '"VideoChannel.Account.Actor.id"', | ||
398 | '"VideoChannel->Account->Actor"."preferredUsername"': '"VideoChannel.Account.Actor.preferredUsername"', | ||
399 | '"VideoChannel->Account->Actor"."url"': '"VideoChannel.Account.Actor.url"', | ||
400 | '"VideoChannel->Account->Actor"."serverId"': '"VideoChannel.Account.Actor.serverId"', | ||
401 | '"VideoChannel->Account->Actor"."avatarId"': '"VideoChannel.Account.Actor.avatarId"', | ||
402 | '"VideoChannel->Actor->Server"."id"': '"VideoChannel.Actor.Server.id"', | ||
403 | '"VideoChannel->Actor->Server"."host"': '"VideoChannel.Actor.Server.host"', | ||
404 | '"VideoChannel->Actor->Avatar"."id"': '"VideoChannel.Actor.Avatar.id"', | ||
405 | '"VideoChannel->Actor->Avatar"."filename"': '"VideoChannel.Actor.Avatar.filename"', | ||
406 | '"VideoChannel->Actor->Avatar"."fileUrl"': '"VideoChannel.Actor.Avatar.fileUrl"', | ||
407 | '"VideoChannel->Actor->Avatar"."onDisk"': '"VideoChannel.Actor.Avatar.onDisk"', | ||
408 | '"VideoChannel->Actor->Avatar"."createdAt"': '"VideoChannel.Actor.Avatar.createdAt"', | ||
409 | '"VideoChannel->Actor->Avatar"."updatedAt"': '"VideoChannel.Actor.Avatar.updatedAt"', | ||
410 | '"VideoChannel->Account->Actor->Server"."id"': '"VideoChannel.Account.Actor.Server.id"', | ||
411 | '"VideoChannel->Account->Actor->Server"."host"': '"VideoChannel.Account.Actor.Server.host"', | ||
412 | '"VideoChannel->Account->Actor->Avatar"."id"': '"VideoChannel.Account.Actor.Avatar.id"', | ||
413 | '"VideoChannel->Account->Actor->Avatar"."filename"': '"VideoChannel.Account.Actor.Avatar.filename"', | ||
414 | '"VideoChannel->Account->Actor->Avatar"."fileUrl"': '"VideoChannel.Account.Actor.Avatar.fileUrl"', | ||
415 | '"VideoChannel->Account->Actor->Avatar"."onDisk"': '"VideoChannel.Account.Actor.Avatar.onDisk"', | ||
416 | '"VideoChannel->Account->Actor->Avatar"."createdAt"': '"VideoChannel.Account.Actor.Avatar.createdAt"', | ||
417 | '"VideoChannel->Account->Actor->Avatar"."updatedAt"': '"VideoChannel.Account.Actor.Avatar.updatedAt"', | ||
418 | '"Thumbnails"."id"': '"Thumbnails.id"', | ||
419 | '"Thumbnails"."type"': '"Thumbnails.type"', | ||
420 | '"Thumbnails"."filename"': '"Thumbnails.filename"' | ||
421 | } | ||
422 | |||
423 | const joins = [ | ||
424 | 'INNER JOIN "video" ON "tmp"."id" = "video"."id"', | ||
425 | |||
426 | 'INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"', | ||
427 | 'INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"', | ||
428 | 'INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"', | ||
429 | 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"', | ||
430 | |||
431 | 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"', | ||
432 | 'LEFT OUTER JOIN "avatar" AS "VideoChannel->Actor->Avatar" ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"', | ||
433 | |||
434 | 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' + | ||
435 | 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"', | ||
436 | |||
437 | 'LEFT OUTER JOIN "avatar" AS "VideoChannel->Account->Actor->Avatar" ' + | ||
438 | 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"', | ||
439 | |||
440 | 'LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"' | ||
441 | ] | ||
442 | |||
443 | if (options.withFiles) { | ||
444 | joins.push('INNER JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"') | ||
445 | |||
446 | Object.assign(attributes, { | ||
447 | '"VideoFiles"."id"': '"VideoFiles.id"', | ||
448 | '"VideoFiles"."createdAt"': '"VideoFiles.createdAt"', | ||
449 | '"VideoFiles"."updatedAt"': '"VideoFiles.updatedAt"', | ||
450 | '"VideoFiles"."resolution"': '"VideoFiles.resolution"', | ||
451 | '"VideoFiles"."size"': '"VideoFiles.size"', | ||
452 | '"VideoFiles"."extname"': '"VideoFiles.extname"', | ||
453 | '"VideoFiles"."infoHash"': '"VideoFiles.infoHash"', | ||
454 | '"VideoFiles"."fps"': '"VideoFiles.fps"', | ||
455 | '"VideoFiles"."videoId"': '"VideoFiles.videoId"' | ||
456 | }) | ||
457 | } | ||
458 | |||
459 | if (options.user) { | ||
460 | joins.push( | ||
461 | 'LEFT OUTER JOIN "userVideoHistory" ' + | ||
462 | 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId' | ||
463 | ) | ||
464 | replacements.userVideoHistoryId = options.user.id | ||
465 | |||
466 | Object.assign(attributes, { | ||
467 | '"userVideoHistory"."id"': '"userVideoHistory.id"', | ||
468 | '"userVideoHistory"."currentTime"': '"userVideoHistory.currentTime"' | ||
469 | }) | ||
470 | } | ||
471 | |||
472 | if (options.videoPlaylistId) { | ||
473 | joins.push( | ||
474 | 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' + | ||
475 | 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId' | ||
476 | ) | ||
477 | replacements.videoPlaylistId = options.videoPlaylistId | ||
478 | |||
479 | Object.assign(attributes, { | ||
480 | '"VideoPlaylistElement"."createdAt"': '"VideoPlaylistElement.createdAt"', | ||
481 | '"VideoPlaylistElement"."updatedAt"': '"VideoPlaylistElement.updatedAt"', | ||
482 | '"VideoPlaylistElement"."url"': '"VideoPlaylistElement.url"', | ||
483 | '"VideoPlaylistElement"."position"': '"VideoPlaylistElement.position"', | ||
484 | '"VideoPlaylistElement"."startTimestamp"': '"VideoPlaylistElement.startTimestamp"', | ||
485 | '"VideoPlaylistElement"."stopTimestamp"': '"VideoPlaylistElement.stopTimestamp"', | ||
486 | '"VideoPlaylistElement"."videoPlaylistId"': '"VideoPlaylistElement.videoPlaylistId"' | ||
487 | }) | ||
488 | } | ||
489 | |||
490 | const select = 'SELECT ' + Object.keys(attributes).map(key => { | ||
491 | const value = attributes[key] | ||
492 | if (value) return `${key} AS ${value}` | ||
493 | |||
494 | return key | ||
495 | }).join(', ') | ||
496 | |||
497 | return `${select} FROM (${baseQuery}) AS "tmp" ${joins.join(' ')} ${order}` | ||
498 | } | ||
499 | |||
500 | export { | ||
501 | buildListQuery, | ||
502 | wrapForAPIResults | ||
503 | } | ||
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts index 50525b4c2..4bbef75e6 100644 --- a/server/models/video/video-share.ts +++ b/server/models/video/video-share.ts | |||
@@ -2,12 +2,10 @@ import * as Bluebird from 'bluebird' | |||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | 2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' |
3 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | 3 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' |
4 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | 4 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' |
5 | import { AccountModel } from '../account/account' | ||
6 | import { ActorModel } from '../activitypub/actor' | 5 | import { ActorModel } from '../activitypub/actor' |
7 | import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' | 6 | import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' |
8 | import { VideoModel } from './video' | 7 | import { VideoModel } from './video' |
9 | import { VideoChannelModel } from './video-channel' | 8 | import { literal, Op, Transaction } from 'sequelize' |
10 | import { Op, Transaction } from 'sequelize' | ||
11 | import { MVideoShareActor, MVideoShareFull } from '../../typings/models/video' | 9 | import { MVideoShareActor, MVideoShareFull } from '../../typings/models/video' |
12 | import { MActorDefault } from '../../typings/models' | 10 | import { MActorDefault } from '../../typings/models' |
13 | 11 | ||
@@ -124,70 +122,55 @@ export class VideoShareModel extends Model<VideoShareModel> { | |||
124 | } | 122 | } |
125 | 123 | ||
126 | return VideoShareModel.scope(ScopeNames.FULL).findAll(query) | 124 | return VideoShareModel.scope(ScopeNames.FULL).findAll(query) |
127 | .then((res: MVideoShareFull[]) => res.map(r => r.Actor)) | 125 | .then((res: MVideoShareFull[]) => res.map(r => r.Actor)) |
128 | } | 126 | } |
129 | 127 | ||
130 | static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Transaction): Bluebird<MActorDefault[]> { | 128 | static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Transaction): Bluebird<MActorDefault[]> { |
129 | const safeOwnerId = parseInt(actorOwnerId + '', 10) | ||
130 | |||
131 | // /!\ On actor model | ||
131 | const query = { | 132 | const query = { |
132 | attributes: [], | 133 | where: { |
133 | include: [ | 134 | [Op.and]: [ |
134 | { | 135 | literal( |
135 | model: ActorModel, | 136 | `EXISTS (` + |
136 | required: true | 137 | ` SELECT 1 FROM "videoShare" ` + |
137 | }, | 138 | ` INNER JOIN "video" ON "videoShare"."videoId" = "video"."id" ` + |
138 | { | 139 | ` INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` + |
139 | attributes: [], | 140 | ` INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ` + |
140 | model: VideoModel, | 141 | ` WHERE "videoShare"."actorId" = "ActorModel"."id" AND "account"."actorId" = ${safeOwnerId} ` + |
141 | required: true, | 142 | ` LIMIT 1` + |
142 | include: [ | 143 | `)` |
143 | { | 144 | ) |
144 | attributes: [], | 145 | ] |
145 | model: VideoChannelModel.unscoped(), | 146 | }, |
146 | required: true, | ||
147 | include: [ | ||
148 | { | ||
149 | attributes: [], | ||
150 | model: AccountModel.unscoped(), | ||
151 | required: true, | ||
152 | where: { | ||
153 | actorId: actorOwnerId | ||
154 | } | ||
155 | } | ||
156 | ] | ||
157 | } | ||
158 | ] | ||
159 | } | ||
160 | ], | ||
161 | transaction: t | 147 | transaction: t |
162 | } | 148 | } |
163 | 149 | ||
164 | return VideoShareModel.scope(ScopeNames.FULL).findAll(query) | 150 | return ActorModel.findAll(query) |
165 | .then(res => res.map(r => r.Actor)) | ||
166 | } | 151 | } |
167 | 152 | ||
168 | static loadActorsByVideoChannel (videoChannelId: number, t: Transaction): Bluebird<MActorDefault[]> { | 153 | static loadActorsByVideoChannel (videoChannelId: number, t: Transaction): Bluebird<MActorDefault[]> { |
154 | const safeChannelId = parseInt(videoChannelId + '', 10) | ||
155 | |||
156 | // /!\ On actor model | ||
169 | const query = { | 157 | const query = { |
170 | attributes: [], | 158 | where: { |
171 | include: [ | 159 | [Op.and]: [ |
172 | { | 160 | literal( |
173 | model: ActorModel, | 161 | `EXISTS (` + |
174 | required: true | 162 | ` SELECT 1 FROM "videoShare" ` + |
175 | }, | 163 | ` INNER JOIN "video" ON "videoShare"."videoId" = "video"."id" ` + |
176 | { | 164 | ` WHERE "videoShare"."actorId" = "ActorModel"."id" AND "video"."channelId" = ${safeChannelId} ` + |
177 | attributes: [], | 165 | ` LIMIT 1` + |
178 | model: VideoModel, | 166 | `)` |
179 | required: true, | 167 | ) |
180 | where: { | 168 | ] |
181 | channelId: videoChannelId | 169 | }, |
182 | } | ||
183 | } | ||
184 | ], | ||
185 | transaction: t | 170 | transaction: t |
186 | } | 171 | } |
187 | 172 | ||
188 | return VideoShareModel.scope(ScopeNames.FULL) | 173 | return ActorModel.findAll(query) |
189 | .findAll(query) | ||
190 | .then(res => res.map(r => r.Actor)) | ||
191 | } | 174 | } |
192 | 175 | ||
193 | static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction) { | 176 | static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction) { |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index a91a7663d..f5194e259 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -1,18 +1,7 @@ | |||
1 | import * as Bluebird from 'bluebird' | 1 | import * as Bluebird from 'bluebird' |
2 | import { maxBy, minBy } from 'lodash' | 2 | import { maxBy, minBy, pick } from 'lodash' |
3 | import { join } from 'path' | 3 | import { join } from 'path' |
4 | import { | 4 | import { FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' |
5 | CountOptions, | ||
6 | FindOptions, | ||
7 | IncludeOptions, | ||
8 | ModelIndexesOptions, | ||
9 | Op, | ||
10 | QueryTypes, | ||
11 | ScopeOptions, | ||
12 | Sequelize, | ||
13 | Transaction, | ||
14 | WhereOptions | ||
15 | } from 'sequelize' | ||
16 | import { | 5 | import { |
17 | AllowNull, | 6 | AllowNull, |
18 | BeforeDestroy, | 7 | BeforeDestroy, |
@@ -54,7 +43,6 @@ import { | |||
54 | } from '../../helpers/custom-validators/videos' | 43 | } from '../../helpers/custom-validators/videos' |
55 | import { getVideoFileResolution } from '../../helpers/ffmpeg-utils' | 44 | import { getVideoFileResolution } from '../../helpers/ffmpeg-utils' |
56 | import { logger } from '../../helpers/logger' | 45 | import { logger } from '../../helpers/logger' |
57 | import { getServerActor } from '../../helpers/utils' | ||
58 | import { | 46 | import { |
59 | ACTIVITY_PUB, | 47 | ACTIVITY_PUB, |
60 | API_VERSION, | 48 | API_VERSION, |
@@ -76,16 +64,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate' | |||
76 | import { ActorModel } from '../activitypub/actor' | 64 | import { ActorModel } from '../activitypub/actor' |
77 | import { AvatarModel } from '../avatar/avatar' | 65 | import { AvatarModel } from '../avatar/avatar' |
78 | import { ServerModel } from '../server/server' | 66 | import { ServerModel } from '../server/server' |
79 | import { | 67 | import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' |
80 | buildBlockedAccountSQL, | ||
81 | buildTrigramSearchIndex, | ||
82 | buildWhereIdOrUUID, | ||
83 | createSafeIn, | ||
84 | createSimilarityAttribute, | ||
85 | getVideoSort, | ||
86 | isOutdated, | ||
87 | throwIfNotValid | ||
88 | } from '../utils' | ||
89 | import { TagModel } from './tag' | 68 | import { TagModel } from './tag' |
90 | import { VideoAbuseModel } from './video-abuse' | 69 | import { VideoAbuseModel } from './video-abuse' |
91 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | 70 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' |
@@ -132,6 +111,7 @@ import { | |||
132 | MVideoForUser, | 111 | MVideoForUser, |
133 | MVideoFullLight, | 112 | MVideoFullLight, |
134 | MVideoIdThumbnail, | 113 | MVideoIdThumbnail, |
114 | MVideoImmutable, | ||
135 | MVideoThumbnail, | 115 | MVideoThumbnail, |
136 | MVideoThumbnailBlacklist, | 116 | MVideoThumbnailBlacklist, |
137 | MVideoWithAllFiles, | 117 | MVideoWithAllFiles, |
@@ -142,75 +122,10 @@ import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../typings/mode | |||
142 | import { MThumbnail } from '../../typings/models/video/thumbnail' | 122 | import { MThumbnail } from '../../typings/models/video/thumbnail' |
143 | import { VideoFile } from '@shared/models/videos/video-file.model' | 123 | import { VideoFile } from '@shared/models/videos/video-file.model' |
144 | import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' | 124 | import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' |
145 | import validator from 'validator' | 125 | import { ModelCache } from '@server/models/model-cache' |
146 | 126 | import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder' | |
147 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | 127 | import { buildNSFWFilter } from '@server/helpers/express-utils' |
148 | const indexes: (ModelIndexesOptions & { where?: WhereOptions })[] = [ | 128 | import { getServerActor } from '@server/models/application/application' |
149 | buildTrigramSearchIndex('video_name_trigram', 'name'), | ||
150 | |||
151 | { fields: [ 'createdAt' ] }, | ||
152 | { | ||
153 | fields: [ | ||
154 | { name: 'publishedAt', order: 'DESC' }, | ||
155 | { name: 'id', order: 'ASC' } | ||
156 | ] | ||
157 | }, | ||
158 | { fields: [ 'duration' ] }, | ||
159 | { fields: [ 'views' ] }, | ||
160 | { fields: [ 'channelId' ] }, | ||
161 | { | ||
162 | fields: [ 'originallyPublishedAt' ], | ||
163 | where: { | ||
164 | originallyPublishedAt: { | ||
165 | [Op.ne]: null | ||
166 | } | ||
167 | } | ||
168 | }, | ||
169 | { | ||
170 | fields: [ 'category' ], // We don't care videos with an unknown category | ||
171 | where: { | ||
172 | category: { | ||
173 | [Op.ne]: null | ||
174 | } | ||
175 | } | ||
176 | }, | ||
177 | { | ||
178 | fields: [ 'licence' ], // We don't care videos with an unknown licence | ||
179 | where: { | ||
180 | licence: { | ||
181 | [Op.ne]: null | ||
182 | } | ||
183 | } | ||
184 | }, | ||
185 | { | ||
186 | fields: [ 'language' ], // We don't care videos with an unknown language | ||
187 | where: { | ||
188 | language: { | ||
189 | [Op.ne]: null | ||
190 | } | ||
191 | } | ||
192 | }, | ||
193 | { | ||
194 | fields: [ 'nsfw' ], // Most of the videos are not NSFW | ||
195 | where: { | ||
196 | nsfw: true | ||
197 | } | ||
198 | }, | ||
199 | { | ||
200 | fields: [ 'remote' ], // Only index local videos | ||
201 | where: { | ||
202 | remote: false | ||
203 | } | ||
204 | }, | ||
205 | { | ||
206 | fields: [ 'uuid' ], | ||
207 | unique: true | ||
208 | }, | ||
209 | { | ||
210 | fields: [ 'url' ], | ||
211 | unique: true | ||
212 | } | ||
213 | ] | ||
214 | 129 | ||
215 | export enum ScopeNames { | 130 | export enum ScopeNames { |
216 | AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', | 131 | AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', |
@@ -223,6 +138,7 @@ export enum ScopeNames { | |||
223 | WITH_USER_HISTORY = 'WITH_USER_HISTORY', | 138 | WITH_USER_HISTORY = 'WITH_USER_HISTORY', |
224 | WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', | 139 | WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', |
225 | WITH_USER_ID = 'WITH_USER_ID', | 140 | WITH_USER_ID = 'WITH_USER_ID', |
141 | WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES', | ||
226 | WITH_THUMBNAILS = 'WITH_THUMBNAILS' | 142 | WITH_THUMBNAILS = 'WITH_THUMBNAILS' |
227 | } | 143 | } |
228 | 144 | ||
@@ -266,7 +182,10 @@ export type AvailableForListIDsOptions = { | |||
266 | } | 182 | } |
267 | 183 | ||
268 | @Scopes(() => ({ | 184 | @Scopes(() => ({ |
269 | [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { | 185 | [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: { |
186 | attributes: [ 'id', 'url', 'uuid', 'remote' ] | ||
187 | }, | ||
188 | [ScopeNames.FOR_API]: (options: ForAPIOptions) => { | ||
270 | const query: FindOptions = { | 189 | const query: FindOptions = { |
271 | include: [ | 190 | include: [ |
272 | { | 191 | { |
@@ -291,14 +210,14 @@ export type AvailableForListIDsOptions = { | |||
291 | if (options.ids) { | 210 | if (options.ids) { |
292 | query.where = { | 211 | query.where = { |
293 | id: { | 212 | id: { |
294 | [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken | 213 | [Op.in]: options.ids |
295 | } | 214 | } |
296 | } | 215 | } |
297 | } | 216 | } |
298 | 217 | ||
299 | if (options.withFiles === true) { | 218 | if (options.withFiles === true) { |
300 | query.include.push({ | 219 | query.include.push({ |
301 | model: VideoFileModel.unscoped(), | 220 | model: VideoFileModel, |
302 | required: true | 221 | required: true |
303 | }) | 222 | }) |
304 | } | 223 | } |
@@ -315,276 +234,7 @@ export type AvailableForListIDsOptions = { | |||
315 | 234 | ||
316 | return query | 235 | return query |
317 | }, | 236 | }, |
318 | [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { | 237 | [ScopeNames.WITH_THUMBNAILS]: { |
319 | const whereAnd = options.baseWhere ? [].concat(options.baseWhere) : [] | ||
320 | |||
321 | const query: FindOptions = { | ||
322 | raw: true, | ||
323 | include: [] | ||
324 | } | ||
325 | |||
326 | const attributesType = options.attributesType || 'id' | ||
327 | |||
328 | if (attributesType === 'id') query.attributes = [ 'id' ] | ||
329 | else if (attributesType === 'none') query.attributes = [ ] | ||
330 | |||
331 | whereAnd.push({ | ||
332 | id: { | ||
333 | [ Op.notIn ]: Sequelize.literal( | ||
334 | '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")' | ||
335 | ) | ||
336 | } | ||
337 | }) | ||
338 | |||
339 | if (options.serverAccountId) { | ||
340 | whereAnd.push({ | ||
341 | channelId: { | ||
342 | [ Op.notIn ]: Sequelize.literal( | ||
343 | '(' + | ||
344 | 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' + | ||
345 | buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) + | ||
346 | ')' + | ||
347 | ')' | ||
348 | ) | ||
349 | } | ||
350 | }) | ||
351 | } | ||
352 | |||
353 | // Only list public/published videos | ||
354 | if (!options.filter || options.filter !== 'all-local') { | ||
355 | |||
356 | const publishWhere = { | ||
357 | // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding | ||
358 | [ Op.or ]: [ | ||
359 | { | ||
360 | state: VideoState.PUBLISHED | ||
361 | }, | ||
362 | { | ||
363 | [ Op.and ]: { | ||
364 | state: VideoState.TO_TRANSCODE, | ||
365 | waitTranscoding: false | ||
366 | } | ||
367 | } | ||
368 | ] | ||
369 | } | ||
370 | whereAnd.push(publishWhere) | ||
371 | |||
372 | // List internal videos if the user is logged in | ||
373 | if (options.user) { | ||
374 | const privacyWhere = { | ||
375 | [Op.or]: [ | ||
376 | { | ||
377 | privacy: VideoPrivacy.INTERNAL | ||
378 | }, | ||
379 | { | ||
380 | privacy: VideoPrivacy.PUBLIC | ||
381 | } | ||
382 | ] | ||
383 | } | ||
384 | |||
385 | whereAnd.push(privacyWhere) | ||
386 | } else { // Or only public videos | ||
387 | const privacyWhere = { privacy: VideoPrivacy.PUBLIC } | ||
388 | whereAnd.push(privacyWhere) | ||
389 | } | ||
390 | } | ||
391 | |||
392 | if (options.videoPlaylistId) { | ||
393 | query.include.push({ | ||
394 | attributes: [], | ||
395 | model: VideoPlaylistElementModel.unscoped(), | ||
396 | required: true, | ||
397 | where: { | ||
398 | videoPlaylistId: options.videoPlaylistId | ||
399 | } | ||
400 | }) | ||
401 | |||
402 | query.subQuery = false | ||
403 | } | ||
404 | |||
405 | if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) { | ||
406 | whereAnd.push({ | ||
407 | remote: false | ||
408 | }) | ||
409 | } | ||
410 | |||
411 | if (options.accountId || options.videoChannelId) { | ||
412 | const videoChannelInclude: IncludeOptions = { | ||
413 | attributes: [], | ||
414 | model: VideoChannelModel.unscoped(), | ||
415 | required: true | ||
416 | } | ||
417 | |||
418 | if (options.videoChannelId) { | ||
419 | videoChannelInclude.where = { | ||
420 | id: options.videoChannelId | ||
421 | } | ||
422 | } | ||
423 | |||
424 | if (options.accountId) { | ||
425 | const accountInclude: IncludeOptions = { | ||
426 | attributes: [], | ||
427 | model: AccountModel.unscoped(), | ||
428 | required: true | ||
429 | } | ||
430 | |||
431 | accountInclude.where = { id: options.accountId } | ||
432 | videoChannelInclude.include = [ accountInclude ] | ||
433 | } | ||
434 | |||
435 | query.include.push(videoChannelInclude) | ||
436 | } | ||
437 | |||
438 | if (options.followerActorId) { | ||
439 | let localVideosReq = '' | ||
440 | if (options.includeLocalVideos === true) { | ||
441 | localVideosReq = ' UNION ALL SELECT "video"."id" FROM "video" WHERE remote IS FALSE' | ||
442 | } | ||
443 | |||
444 | // Force actorId to be a number to avoid SQL injections | ||
445 | const actorIdNumber = parseInt(options.followerActorId.toString(), 10) | ||
446 | whereAnd.push({ | ||
447 | id: { | ||
448 | [Op.in]: Sequelize.literal( | ||
449 | '(' + | ||
450 | 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + | ||
451 | 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + | ||
452 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + | ||
453 | ' UNION ALL ' + | ||
454 | 'SELECT "video"."id" AS "id" FROM "video" ' + | ||
455 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
456 | 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' + | ||
457 | 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + | ||
458 | 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' + | ||
459 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + | ||
460 | localVideosReq + | ||
461 | ')' | ||
462 | ) | ||
463 | } | ||
464 | }) | ||
465 | } | ||
466 | |||
467 | if (options.withFiles === true) { | ||
468 | whereAnd.push({ | ||
469 | id: { | ||
470 | [ Op.in ]: Sequelize.literal( | ||
471 | '(SELECT "videoId" FROM "videoFile")' | ||
472 | ) | ||
473 | } | ||
474 | }) | ||
475 | } | ||
476 | |||
477 | // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN() | ||
478 | if (options.tagsAllOf || options.tagsOneOf) { | ||
479 | if (options.tagsOneOf) { | ||
480 | const tagsOneOfLower = options.tagsOneOf.map(t => t.toLowerCase()) | ||
481 | |||
482 | whereAnd.push({ | ||
483 | id: { | ||
484 | [ Op.in ]: Sequelize.literal( | ||
485 | '(' + | ||
486 | 'SELECT "videoId" FROM "videoTag" ' + | ||
487 | 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
488 | 'WHERE lower("tag"."name") IN (' + createSafeIn(VideoModel, tagsOneOfLower) + ')' + | ||
489 | ')' | ||
490 | ) | ||
491 | } | ||
492 | }) | ||
493 | } | ||
494 | |||
495 | if (options.tagsAllOf) { | ||
496 | const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase()) | ||
497 | |||
498 | whereAnd.push({ | ||
499 | id: { | ||
500 | [ Op.in ]: Sequelize.literal( | ||
501 | '(' + | ||
502 | 'SELECT "videoId" FROM "videoTag" ' + | ||
503 | 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
504 | 'WHERE lower("tag"."name") IN (' + createSafeIn(VideoModel, tagsAllOfLower) + ')' + | ||
505 | 'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length + | ||
506 | ')' | ||
507 | ) | ||
508 | } | ||
509 | }) | ||
510 | } | ||
511 | } | ||
512 | |||
513 | if (options.nsfw === true || options.nsfw === false) { | ||
514 | whereAnd.push({ nsfw: options.nsfw }) | ||
515 | } | ||
516 | |||
517 | if (options.categoryOneOf) { | ||
518 | whereAnd.push({ | ||
519 | category: { | ||
520 | [ Op.or ]: options.categoryOneOf | ||
521 | } | ||
522 | }) | ||
523 | } | ||
524 | |||
525 | if (options.licenceOneOf) { | ||
526 | whereAnd.push({ | ||
527 | licence: { | ||
528 | [ Op.or ]: options.licenceOneOf | ||
529 | } | ||
530 | }) | ||
531 | } | ||
532 | |||
533 | if (options.languageOneOf) { | ||
534 | let videoLanguages = options.languageOneOf | ||
535 | if (options.languageOneOf.find(l => l === '_unknown')) { | ||
536 | videoLanguages = videoLanguages.concat([ null ]) | ||
537 | } | ||
538 | |||
539 | whereAnd.push({ | ||
540 | [Op.or]: [ | ||
541 | { | ||
542 | language: { | ||
543 | [ Op.or ]: videoLanguages | ||
544 | } | ||
545 | }, | ||
546 | { | ||
547 | id: { | ||
548 | [ Op.in ]: Sequelize.literal( | ||
549 | '(' + | ||
550 | 'SELECT "videoId" FROM "videoCaption" ' + | ||
551 | 'WHERE "language" IN (' + createSafeIn(VideoModel, options.languageOneOf) + ') ' + | ||
552 | ')' | ||
553 | ) | ||
554 | } | ||
555 | } | ||
556 | ] | ||
557 | }) | ||
558 | } | ||
559 | |||
560 | if (options.trendingDays) { | ||
561 | query.include.push(VideoModel.buildTrendingQuery(options.trendingDays)) | ||
562 | |||
563 | query.subQuery = false | ||
564 | } | ||
565 | |||
566 | if (options.historyOfUser) { | ||
567 | query.include.push({ | ||
568 | model: UserVideoHistoryModel, | ||
569 | required: true, | ||
570 | where: { | ||
571 | userId: options.historyOfUser.id | ||
572 | } | ||
573 | }) | ||
574 | |||
575 | // Even if the relation is n:m, we know that a user only have 0..1 video history | ||
576 | // So we won't have multiple rows for the same video | ||
577 | // Without this, we would not be able to sort on "updatedAt" column of UserVideoHistoryModel | ||
578 | query.subQuery = false | ||
579 | } | ||
580 | |||
581 | query.where = { | ||
582 | [ Op.and ]: whereAnd | ||
583 | } | ||
584 | |||
585 | return query | ||
586 | }, | ||
587 | [ ScopeNames.WITH_THUMBNAILS ]: { | ||
588 | include: [ | 238 | include: [ |
589 | { | 239 | { |
590 | model: ThumbnailModel, | 240 | model: ThumbnailModel, |
@@ -592,7 +242,7 @@ export type AvailableForListIDsOptions = { | |||
592 | } | 242 | } |
593 | ] | 243 | ] |
594 | }, | 244 | }, |
595 | [ ScopeNames.WITH_USER_ID ]: { | 245 | [ScopeNames.WITH_USER_ID]: { |
596 | include: [ | 246 | include: [ |
597 | { | 247 | { |
598 | attributes: [ 'accountId' ], | 248 | attributes: [ 'accountId' ], |
@@ -608,7 +258,7 @@ export type AvailableForListIDsOptions = { | |||
608 | } | 258 | } |
609 | ] | 259 | ] |
610 | }, | 260 | }, |
611 | [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { | 261 | [ScopeNames.WITH_ACCOUNT_DETAILS]: { |
612 | include: [ | 262 | include: [ |
613 | { | 263 | { |
614 | model: VideoChannelModel.unscoped(), | 264 | model: VideoChannelModel.unscoped(), |
@@ -660,10 +310,10 @@ export type AvailableForListIDsOptions = { | |||
660 | } | 310 | } |
661 | ] | 311 | ] |
662 | }, | 312 | }, |
663 | [ ScopeNames.WITH_TAGS ]: { | 313 | [ScopeNames.WITH_TAGS]: { |
664 | include: [ TagModel ] | 314 | include: [ TagModel ] |
665 | }, | 315 | }, |
666 | [ ScopeNames.WITH_BLACKLISTED ]: { | 316 | [ScopeNames.WITH_BLACKLISTED]: { |
667 | include: [ | 317 | include: [ |
668 | { | 318 | { |
669 | attributes: [ 'id', 'reason', 'unfederated' ], | 319 | attributes: [ 'id', 'reason', 'unfederated' ], |
@@ -672,7 +322,7 @@ export type AvailableForListIDsOptions = { | |||
672 | } | 322 | } |
673 | ] | 323 | ] |
674 | }, | 324 | }, |
675 | [ ScopeNames.WITH_WEBTORRENT_FILES ]: (withRedundancies = false) => { | 325 | [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => { |
676 | let subInclude: any[] = [] | 326 | let subInclude: any[] = [] |
677 | 327 | ||
678 | if (withRedundancies === true) { | 328 | if (withRedundancies === true) { |
@@ -688,7 +338,7 @@ export type AvailableForListIDsOptions = { | |||
688 | return { | 338 | return { |
689 | include: [ | 339 | include: [ |
690 | { | 340 | { |
691 | model: VideoFileModel.unscoped(), | 341 | model: VideoFileModel, |
692 | separate: true, // We may have multiple files, having multiple redundancies so let's separate this join | 342 | separate: true, // We may have multiple files, having multiple redundancies so let's separate this join |
693 | required: false, | 343 | required: false, |
694 | include: subInclude | 344 | include: subInclude |
@@ -696,10 +346,10 @@ export type AvailableForListIDsOptions = { | |||
696 | ] | 346 | ] |
697 | } | 347 | } |
698 | }, | 348 | }, |
699 | [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => { | 349 | [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => { |
700 | const subInclude: IncludeOptions[] = [ | 350 | const subInclude: IncludeOptions[] = [ |
701 | { | 351 | { |
702 | model: VideoFileModel.unscoped(), | 352 | model: VideoFileModel, |
703 | required: false | 353 | required: false |
704 | } | 354 | } |
705 | ] | 355 | ] |
@@ -723,7 +373,7 @@ export type AvailableForListIDsOptions = { | |||
723 | ] | 373 | ] |
724 | } | 374 | } |
725 | }, | 375 | }, |
726 | [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { | 376 | [ScopeNames.WITH_SCHEDULED_UPDATE]: { |
727 | include: [ | 377 | include: [ |
728 | { | 378 | { |
729 | model: ScheduleVideoUpdateModel.unscoped(), | 379 | model: ScheduleVideoUpdateModel.unscoped(), |
@@ -731,7 +381,7 @@ export type AvailableForListIDsOptions = { | |||
731 | } | 381 | } |
732 | ] | 382 | ] |
733 | }, | 383 | }, |
734 | [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => { | 384 | [ScopeNames.WITH_USER_HISTORY]: (userId: number) => { |
735 | return { | 385 | return { |
736 | include: [ | 386 | include: [ |
737 | { | 387 | { |
@@ -748,7 +398,72 @@ export type AvailableForListIDsOptions = { | |||
748 | })) | 398 | })) |
749 | @Table({ | 399 | @Table({ |
750 | tableName: 'video', | 400 | tableName: 'video', |
751 | indexes | 401 | indexes: [ |
402 | buildTrigramSearchIndex('video_name_trigram', 'name'), | ||
403 | |||
404 | { fields: [ 'createdAt' ] }, | ||
405 | { | ||
406 | fields: [ | ||
407 | { name: 'publishedAt', order: 'DESC' }, | ||
408 | { name: 'id', order: 'ASC' } | ||
409 | ] | ||
410 | }, | ||
411 | { fields: [ 'duration' ] }, | ||
412 | { fields: [ 'views' ] }, | ||
413 | { fields: [ 'channelId' ] }, | ||
414 | { | ||
415 | fields: [ 'originallyPublishedAt' ], | ||
416 | where: { | ||
417 | originallyPublishedAt: { | ||
418 | [Op.ne]: null | ||
419 | } | ||
420 | } | ||
421 | }, | ||
422 | { | ||
423 | fields: [ 'category' ], // We don't care videos with an unknown category | ||
424 | where: { | ||
425 | category: { | ||
426 | [Op.ne]: null | ||
427 | } | ||
428 | } | ||
429 | }, | ||
430 | { | ||
431 | fields: [ 'licence' ], // We don't care videos with an unknown licence | ||
432 | where: { | ||
433 | licence: { | ||
434 | [Op.ne]: null | ||
435 | } | ||
436 | } | ||
437 | }, | ||
438 | { | ||
439 | fields: [ 'language' ], // We don't care videos with an unknown language | ||
440 | where: { | ||
441 | language: { | ||
442 | [Op.ne]: null | ||
443 | } | ||
444 | } | ||
445 | }, | ||
446 | { | ||
447 | fields: [ 'nsfw' ], // Most of the videos are not NSFW | ||
448 | where: { | ||
449 | nsfw: true | ||
450 | } | ||
451 | }, | ||
452 | { | ||
453 | fields: [ 'remote' ], // Only index local videos | ||
454 | where: { | ||
455 | remote: false | ||
456 | } | ||
457 | }, | ||
458 | { | ||
459 | fields: [ 'uuid' ], | ||
460 | unique: true | ||
461 | }, | ||
462 | { | ||
463 | fields: [ 'url' ], | ||
464 | unique: true | ||
465 | } | ||
466 | ] | ||
752 | }) | 467 | }) |
753 | export class VideoModel extends Model<VideoModel> { | 468 | export class VideoModel extends Model<VideoModel> { |
754 | 469 | ||
@@ -913,9 +628,9 @@ export class VideoModel extends Model<VideoModel> { | |||
913 | @HasMany(() => VideoAbuseModel, { | 628 | @HasMany(() => VideoAbuseModel, { |
914 | foreignKey: { | 629 | foreignKey: { |
915 | name: 'videoId', | 630 | name: 'videoId', |
916 | allowNull: false | 631 | allowNull: true |
917 | }, | 632 | }, |
918 | onDelete: 'cascade' | 633 | onDelete: 'set null' |
919 | }) | 634 | }) |
920 | VideoAbuses: VideoAbuseModel[] | 635 | VideoAbuses: VideoAbuseModel[] |
921 | 636 | ||
@@ -1019,7 +734,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1019 | }, | 734 | }, |
1020 | onDelete: 'cascade', | 735 | onDelete: 'cascade', |
1021 | hooks: true, | 736 | hooks: true, |
1022 | [ 'separate' as any ]: true | 737 | ['separate' as any]: true |
1023 | }) | 738 | }) |
1024 | VideoCaptions: VideoCaptionModel[] | 739 | VideoCaptions: VideoCaptionModel[] |
1025 | 740 | ||
@@ -1078,6 +793,38 @@ export class VideoModel extends Model<VideoModel> { | |||
1078 | return undefined | 793 | return undefined |
1079 | } | 794 | } |
1080 | 795 | ||
796 | @BeforeDestroy | ||
797 | static invalidateCache (instance: VideoModel) { | ||
798 | ModelCache.Instance.invalidateCache('video', instance.id) | ||
799 | } | ||
800 | |||
801 | @BeforeDestroy | ||
802 | static async saveEssentialDataToAbuses (instance: VideoModel, options) { | ||
803 | const tasks: Promise<any>[] = [] | ||
804 | |||
805 | logger.info('Saving video abuses details of video %s.', instance.url) | ||
806 | |||
807 | if (!Array.isArray(instance.VideoAbuses)) { | ||
808 | instance.VideoAbuses = await instance.$get('VideoAbuses') | ||
809 | |||
810 | if (instance.VideoAbuses.length === 0) return undefined | ||
811 | } | ||
812 | |||
813 | const details = instance.toFormattedDetailsJSON() | ||
814 | |||
815 | for (const abuse of instance.VideoAbuses) { | ||
816 | abuse.deletedVideo = details | ||
817 | tasks.push(abuse.save({ transaction: options.transaction })) | ||
818 | } | ||
819 | |||
820 | Promise.all(tasks) | ||
821 | .catch(err => { | ||
822 | logger.error('Some errors when saving details of video %s in its abuses before destroy hook.', instance.uuid, { err }) | ||
823 | }) | ||
824 | |||
825 | return undefined | ||
826 | } | ||
827 | |||
1081 | static listLocal (): Bluebird<MVideoWithAllFiles[]> { | 828 | static listLocal (): Bluebird<MVideoWithAllFiles[]> { |
1082 | const query = { | 829 | const query = { |
1083 | where: { | 830 | where: { |
@@ -1112,19 +859,19 @@ export class VideoModel extends Model<VideoModel> { | |||
1112 | distinct: true, | 859 | distinct: true, |
1113 | offset: start, | 860 | offset: start, |
1114 | limit: count, | 861 | limit: count, |
1115 | order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings | 862 | order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings |
1116 | where: { | 863 | where: { |
1117 | id: { | 864 | id: { |
1118 | [ Op.in ]: Sequelize.literal('(' + rawQuery + ')') | 865 | [Op.in]: Sequelize.literal('(' + rawQuery + ')') |
1119 | }, | 866 | }, |
1120 | [ Op.or ]: [ | 867 | [Op.or]: [ |
1121 | { privacy: VideoPrivacy.PUBLIC }, | 868 | { privacy: VideoPrivacy.PUBLIC }, |
1122 | { privacy: VideoPrivacy.UNLISTED } | 869 | { privacy: VideoPrivacy.UNLISTED } |
1123 | ] | 870 | ] |
1124 | }, | 871 | }, |
1125 | include: [ | 872 | include: [ |
1126 | { | 873 | { |
1127 | attributes: [ 'language' ], | 874 | attributes: [ 'language', 'fileUrl' ], |
1128 | model: VideoCaptionModel.unscoped(), | 875 | model: VideoCaptionModel.unscoped(), |
1129 | required: false | 876 | required: false |
1130 | }, | 877 | }, |
@@ -1134,10 +881,10 @@ export class VideoModel extends Model<VideoModel> { | |||
1134 | required: false, | 881 | required: false, |
1135 | // We only want videos shared by this actor | 882 | // We only want videos shared by this actor |
1136 | where: { | 883 | where: { |
1137 | [ Op.and ]: [ | 884 | [Op.and]: [ |
1138 | { | 885 | { |
1139 | id: { | 886 | id: { |
1140 | [ Op.not ]: null | 887 | [Op.not]: null |
1141 | } | 888 | } |
1142 | }, | 889 | }, |
1143 | { | 890 | { |
@@ -1187,8 +934,8 @@ export class VideoModel extends Model<VideoModel> { | |||
1187 | // totals: totalVideos + totalVideoShares | 934 | // totals: totalVideos + totalVideoShares |
1188 | let totalVideos = 0 | 935 | let totalVideos = 0 |
1189 | let totalVideoShares = 0 | 936 | let totalVideoShares = 0 |
1190 | if (totals[ 0 ]) totalVideos = parseInt(totals[ 0 ].total, 10) | 937 | if (totals[0]) totalVideos = parseInt(totals[0].total, 10) |
1191 | if (totals[ 1 ]) totalVideoShares = parseInt(totals[ 1 ].total, 10) | 938 | if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10) |
1192 | 939 | ||
1193 | const total = totalVideos + totalVideoShares | 940 | const total = totalVideos + totalVideoShares |
1194 | return { | 941 | return { |
@@ -1231,7 +978,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1231 | baseQuery = Object.assign(baseQuery, { | 978 | baseQuery = Object.assign(baseQuery, { |
1232 | where: { | 979 | where: { |
1233 | name: { | 980 | name: { |
1234 | [ Op.iLike ]: '%' + search + '%' | 981 | [Op.iLike]: '%' + search + '%' |
1235 | } | 982 | } |
1236 | } | 983 | } |
1237 | }) | 984 | }) |
@@ -1261,50 +1008,46 @@ export class VideoModel extends Model<VideoModel> { | |||
1261 | } | 1008 | } |
1262 | 1009 | ||
1263 | static async listForApi (options: { | 1010 | static async listForApi (options: { |
1264 | start: number, | 1011 | start: number |
1265 | count: number, | 1012 | count: number |
1266 | sort: string, | 1013 | sort: string |
1267 | nsfw: boolean, | 1014 | nsfw: boolean |
1268 | includeLocalVideos: boolean, | 1015 | includeLocalVideos: boolean |
1269 | withFiles: boolean, | 1016 | withFiles: boolean |
1270 | categoryOneOf?: number[], | 1017 | categoryOneOf?: number[] |
1271 | licenceOneOf?: number[], | 1018 | licenceOneOf?: number[] |
1272 | languageOneOf?: string[], | 1019 | languageOneOf?: string[] |
1273 | tagsOneOf?: string[], | 1020 | tagsOneOf?: string[] |
1274 | tagsAllOf?: string[], | 1021 | tagsAllOf?: string[] |
1275 | filter?: VideoFilter, | 1022 | filter?: VideoFilter |
1276 | accountId?: number, | 1023 | accountId?: number |
1277 | videoChannelId?: number, | 1024 | videoChannelId?: number |
1278 | followerActorId?: number | 1025 | followerActorId?: number |
1279 | videoPlaylistId?: number, | 1026 | videoPlaylistId?: number |
1280 | trendingDays?: number, | 1027 | trendingDays?: number |
1281 | user?: MUserAccountId, | 1028 | user?: MUserAccountId |
1282 | historyOfUser?: MUserId, | 1029 | historyOfUser?: MUserId |
1283 | countVideos?: boolean | 1030 | countVideos?: boolean |
1284 | }) { | 1031 | }) { |
1285 | if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { | 1032 | if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { |
1286 | throw new Error('Try to filter all-local but no user has not the see all videos right') | 1033 | throw new Error('Try to filter all-local but no user has not the see all videos right') |
1287 | } | 1034 | } |
1288 | 1035 | ||
1289 | const query: FindOptions & { where?: null } = { | 1036 | const trendingDays = options.sort.endsWith('trending') |
1290 | offset: options.start, | 1037 | ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS |
1291 | limit: options.count, | 1038 | : undefined |
1292 | order: getVideoSort(options.sort) | ||
1293 | } | ||
1294 | |||
1295 | let trendingDays: number | ||
1296 | if (options.sort.endsWith('trending')) { | ||
1297 | trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS | ||
1298 | |||
1299 | query.group = 'VideoModel.id' | ||
1300 | } | ||
1301 | 1039 | ||
1302 | const serverActor = await getServerActor() | 1040 | const serverActor = await getServerActor() |
1303 | 1041 | ||
1304 | // followerActorId === null has a meaning, so just check undefined | 1042 | // followerActorId === null has a meaning, so just check undefined |
1305 | const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id | 1043 | const followerActorId = options.followerActorId !== undefined |
1044 | ? options.followerActorId | ||
1045 | : serverActor.id | ||
1306 | 1046 | ||
1307 | const queryOptions = { | 1047 | const queryOptions = { |
1048 | start: options.start, | ||
1049 | count: options.count, | ||
1050 | sort: options.sort, | ||
1308 | followerActorId, | 1051 | followerActorId, |
1309 | serverAccountId: serverActor.Account.id, | 1052 | serverAccountId: serverActor.Account.id, |
1310 | nsfw: options.nsfw, | 1053 | nsfw: options.nsfw, |
@@ -1324,7 +1067,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1324 | trendingDays | 1067 | trendingDays |
1325 | } | 1068 | } |
1326 | 1069 | ||
1327 | return VideoModel.getAvailableForApi(query, queryOptions, options.countVideos) | 1070 | return VideoModel.getAvailableForApi(queryOptions, options.countVideos) |
1328 | } | 1071 | } |
1329 | 1072 | ||
1330 | static async searchAndPopulateAccountAndServer (options: { | 1073 | static async searchAndPopulateAccountAndServer (options: { |
@@ -1345,91 +1088,9 @@ export class VideoModel extends Model<VideoModel> { | |||
1345 | tagsAllOf?: string[] | 1088 | tagsAllOf?: string[] |
1346 | durationMin?: number // seconds | 1089 | durationMin?: number // seconds |
1347 | durationMax?: number // seconds | 1090 | durationMax?: number // seconds |
1348 | user?: MUserAccountId, | 1091 | user?: MUserAccountId |
1349 | filter?: VideoFilter | 1092 | filter?: VideoFilter |
1350 | }) { | 1093 | }) { |
1351 | const whereAnd = [] | ||
1352 | |||
1353 | if (options.startDate || options.endDate) { | ||
1354 | const publishedAtRange = {} | ||
1355 | |||
1356 | if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate | ||
1357 | if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate | ||
1358 | |||
1359 | whereAnd.push({ publishedAt: publishedAtRange }) | ||
1360 | } | ||
1361 | |||
1362 | if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) { | ||
1363 | const originallyPublishedAtRange = {} | ||
1364 | |||
1365 | if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate | ||
1366 | if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate | ||
1367 | |||
1368 | whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange }) | ||
1369 | } | ||
1370 | |||
1371 | if (options.durationMin || options.durationMax) { | ||
1372 | const durationRange = {} | ||
1373 | |||
1374 | if (options.durationMin) durationRange[ Op.gte ] = options.durationMin | ||
1375 | if (options.durationMax) durationRange[ Op.lte ] = options.durationMax | ||
1376 | |||
1377 | whereAnd.push({ duration: durationRange }) | ||
1378 | } | ||
1379 | |||
1380 | const attributesInclude = [] | ||
1381 | const escapedSearch = VideoModel.sequelize.escape(options.search) | ||
1382 | const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') | ||
1383 | if (options.search) { | ||
1384 | const trigramSearch = { | ||
1385 | id: { | ||
1386 | [ Op.in ]: Sequelize.literal( | ||
1387 | '(' + | ||
1388 | 'SELECT "video"."id" FROM "video" ' + | ||
1389 | 'WHERE ' + | ||
1390 | 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' + | ||
1391 | 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + | ||
1392 | 'UNION ALL ' + | ||
1393 | 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' + | ||
1394 | 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
1395 | 'WHERE lower("tag"."name") = lower(' + escapedSearch + ')' + | ||
1396 | ')' | ||
1397 | ) | ||
1398 | } | ||
1399 | } | ||
1400 | |||
1401 | if (validator.isUUID(options.search)) { | ||
1402 | whereAnd.push({ | ||
1403 | [Op.or]: [ | ||
1404 | trigramSearch, | ||
1405 | { | ||
1406 | uuid: options.search | ||
1407 | } | ||
1408 | ] | ||
1409 | }) | ||
1410 | } else { | ||
1411 | whereAnd.push(trigramSearch) | ||
1412 | } | ||
1413 | |||
1414 | attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search)) | ||
1415 | } | ||
1416 | |||
1417 | // Cannot search on similarity if we don't have a search | ||
1418 | if (!options.search) { | ||
1419 | attributesInclude.push( | ||
1420 | Sequelize.literal('0 as similarity') | ||
1421 | ) | ||
1422 | } | ||
1423 | |||
1424 | const query = { | ||
1425 | attributes: { | ||
1426 | include: attributesInclude | ||
1427 | }, | ||
1428 | offset: options.start, | ||
1429 | limit: options.count, | ||
1430 | order: getVideoSort(options.sort) | ||
1431 | } | ||
1432 | |||
1433 | const serverActor = await getServerActor() | 1094 | const serverActor = await getServerActor() |
1434 | const queryOptions = { | 1095 | const queryOptions = { |
1435 | followerActorId: serverActor.id, | 1096 | followerActorId: serverActor.id, |
@@ -1443,10 +1104,21 @@ export class VideoModel extends Model<VideoModel> { | |||
1443 | tagsAllOf: options.tagsAllOf, | 1104 | tagsAllOf: options.tagsAllOf, |
1444 | user: options.user, | 1105 | user: options.user, |
1445 | filter: options.filter, | 1106 | filter: options.filter, |
1446 | baseWhere: whereAnd | 1107 | start: options.start, |
1108 | count: options.count, | ||
1109 | sort: options.sort, | ||
1110 | startDate: options.startDate, | ||
1111 | endDate: options.endDate, | ||
1112 | originallyPublishedStartDate: options.originallyPublishedStartDate, | ||
1113 | originallyPublishedEndDate: options.originallyPublishedEndDate, | ||
1114 | |||
1115 | durationMin: options.durationMin, | ||
1116 | durationMax: options.durationMax, | ||
1117 | |||
1118 | search: options.search | ||
1447 | } | 1119 | } |
1448 | 1120 | ||
1449 | return VideoModel.getAvailableForApi(query, queryOptions) | 1121 | return VideoModel.getAvailableForApi(queryOptions) |
1450 | } | 1122 | } |
1451 | 1123 | ||
1452 | static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> { | 1124 | static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> { |
@@ -1472,6 +1144,24 @@ export class VideoModel extends Model<VideoModel> { | |||
1472 | ]).findOne(options) | 1144 | ]).findOne(options) |
1473 | } | 1145 | } |
1474 | 1146 | ||
1147 | static loadImmutableAttributes (id: number | string, t?: Transaction): Bluebird<MVideoImmutable> { | ||
1148 | const fun = () => { | ||
1149 | const query = { | ||
1150 | where: buildWhereIdOrUUID(id), | ||
1151 | transaction: t | ||
1152 | } | ||
1153 | |||
1154 | return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query) | ||
1155 | } | ||
1156 | |||
1157 | return ModelCache.Instance.doCache({ | ||
1158 | cacheType: 'load-video-immutable-id', | ||
1159 | key: '' + id, | ||
1160 | deleteKey: 'video', | ||
1161 | fun | ||
1162 | }) | ||
1163 | } | ||
1164 | |||
1475 | static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> { | 1165 | static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> { |
1476 | const where = buildWhereIdOrUUID(id) | 1166 | const where = buildWhereIdOrUUID(id) |
1477 | const options = { | 1167 | const options = { |
@@ -1535,6 +1225,26 @@ export class VideoModel extends Model<VideoModel> { | |||
1535 | return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query) | 1225 | return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query) |
1536 | } | 1226 | } |
1537 | 1227 | ||
1228 | static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Bluebird<MVideoImmutable> { | ||
1229 | const fun = () => { | ||
1230 | const query: FindOptions = { | ||
1231 | where: { | ||
1232 | url | ||
1233 | }, | ||
1234 | transaction | ||
1235 | } | ||
1236 | |||
1237 | return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query) | ||
1238 | } | ||
1239 | |||
1240 | return ModelCache.Instance.doCache({ | ||
1241 | cacheType: 'load-video-immutable-url', | ||
1242 | key: url, | ||
1243 | deleteKey: 'video', | ||
1244 | fun | ||
1245 | }) | ||
1246 | } | ||
1247 | |||
1538 | static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> { | 1248 | static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> { |
1539 | const query: FindOptions = { | 1249 | const query: FindOptions = { |
1540 | where: { | 1250 | where: { |
@@ -1581,8 +1291,8 @@ export class VideoModel extends Model<VideoModel> { | |||
1581 | } | 1291 | } |
1582 | 1292 | ||
1583 | static loadForGetAPI (parameters: { | 1293 | static loadForGetAPI (parameters: { |
1584 | id: number | string, | 1294 | id: number | string |
1585 | t?: Transaction, | 1295 | t?: Transaction |
1586 | userId?: number | 1296 | userId?: number |
1587 | }): Bluebird<MVideoDetails> { | 1297 | }): Bluebird<MVideoDetails> { |
1588 | const { id, t, userId } = parameters | 1298 | const { id, t, userId } = parameters |
@@ -1619,16 +1329,25 @@ export class VideoModel extends Model<VideoModel> { | |||
1619 | remote: false | 1329 | remote: false |
1620 | } | 1330 | } |
1621 | }) | 1331 | }) |
1622 | const totalVideos = await VideoModel.count() | ||
1623 | 1332 | ||
1624 | let totalLocalVideoViews = await VideoModel.sum('views', { | 1333 | let totalLocalVideoViews = await VideoModel.sum('views', { |
1625 | where: { | 1334 | where: { |
1626 | remote: false | 1335 | remote: false |
1627 | } | 1336 | } |
1628 | }) | 1337 | }) |
1338 | |||
1629 | // Sequelize could return null... | 1339 | // Sequelize could return null... |
1630 | if (!totalLocalVideoViews) totalLocalVideoViews = 0 | 1340 | if (!totalLocalVideoViews) totalLocalVideoViews = 0 |
1631 | 1341 | ||
1342 | const { total: totalVideos } = await VideoModel.listForApi({ | ||
1343 | start: 0, | ||
1344 | count: 0, | ||
1345 | sort: '-publishedAt', | ||
1346 | nsfw: buildNSFWFilter(), | ||
1347 | includeLocalVideos: true, | ||
1348 | withFiles: false | ||
1349 | }) | ||
1350 | |||
1632 | return { | 1351 | return { |
1633 | totalLocalVideos, | 1352 | totalLocalVideos, |
1634 | totalLocalVideoViews, | 1353 | totalLocalVideoViews, |
@@ -1648,9 +1367,9 @@ export class VideoModel extends Model<VideoModel> { | |||
1648 | static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) { | 1367 | static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) { |
1649 | // Instances only share videos | 1368 | // Instances only share videos |
1650 | const query = 'SELECT 1 FROM "videoShare" ' + | 1369 | const query = 'SELECT 1 FROM "videoShare" ' + |
1651 | 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + | 1370 | 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + |
1652 | 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' + | 1371 | 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' + |
1653 | 'LIMIT 1' | 1372 | 'LIMIT 1' |
1654 | 1373 | ||
1655 | const options = { | 1374 | const options = { |
1656 | type: QueryTypes.SELECT as QueryTypes.SELECT, | 1375 | type: QueryTypes.SELECT as QueryTypes.SELECT, |
@@ -1682,7 +1401,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1682 | } | 1401 | } |
1683 | 1402 | ||
1684 | return VideoModel.findAll(query) | 1403 | return VideoModel.findAll(query) |
1685 | .then(videos => videos.map(v => v.id)) | 1404 | .then(videos => videos.map(v => v.id)) |
1686 | } | 1405 | } |
1687 | 1406 | ||
1688 | // threshold corresponds to how many video the field should have to be returned | 1407 | // threshold corresponds to how many video the field should have to be returned |
@@ -1690,26 +1409,22 @@ export class VideoModel extends Model<VideoModel> { | |||
1690 | const serverActor = await getServerActor() | 1409 | const serverActor = await getServerActor() |
1691 | const followerActorId = serverActor.id | 1410 | const followerActorId = serverActor.id |
1692 | 1411 | ||
1693 | const scopeOptions: AvailableForListIDsOptions = { | 1412 | const queryOptions: BuildVideosQueryOptions = { |
1413 | attributes: [ `"${field}"` ], | ||
1414 | group: `GROUP BY "${field}"`, | ||
1415 | having: `HAVING COUNT("${field}") >= ${threshold}`, | ||
1416 | start: 0, | ||
1417 | sort: 'random', | ||
1418 | count, | ||
1694 | serverAccountId: serverActor.Account.id, | 1419 | serverAccountId: serverActor.Account.id, |
1695 | followerActorId, | 1420 | followerActorId, |
1696 | includeLocalVideos: true, | 1421 | includeLocalVideos: true |
1697 | attributesType: 'none' // Don't break aggregation | ||
1698 | } | 1422 | } |
1699 | 1423 | ||
1700 | const query: FindOptions = { | 1424 | const { query, replacements } = buildListQuery(VideoModel, queryOptions) |
1701 | attributes: [ field ], | ||
1702 | limit: count, | ||
1703 | group: field, | ||
1704 | having: Sequelize.where( | ||
1705 | Sequelize.fn('COUNT', Sequelize.col(field)), { [ Op.gte ]: threshold } | ||
1706 | ), | ||
1707 | order: [ (this.sequelize as any).random() ] | ||
1708 | } | ||
1709 | 1425 | ||
1710 | return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] }) | 1426 | return this.sequelize.query<any>(query, { replacements, type: QueryTypes.SELECT }) |
1711 | .findAll(query) | 1427 | .then(rows => rows.map(r => r[field])) |
1712 | .then(rows => rows.map(r => r[ field ])) | ||
1713 | } | 1428 | } |
1714 | 1429 | ||
1715 | static buildTrendingQuery (trendingDays: number) { | 1430 | static buildTrendingQuery (trendingDays: number) { |
@@ -1720,42 +1435,37 @@ export class VideoModel extends Model<VideoModel> { | |||
1720 | required: false, | 1435 | required: false, |
1721 | where: { | 1436 | where: { |
1722 | startDate: { | 1437 | startDate: { |
1723 | [ Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) | 1438 | [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) |
1724 | } | 1439 | } |
1725 | } | 1440 | } |
1726 | } | 1441 | } |
1727 | } | 1442 | } |
1728 | 1443 | ||
1729 | private static async getAvailableForApi ( | 1444 | private static async getAvailableForApi ( |
1730 | query: FindOptions & { where?: null }, // Forbid where field in query | 1445 | options: BuildVideosQueryOptions, |
1731 | options: AvailableForListIDsOptions, | ||
1732 | countVideos = true | 1446 | countVideos = true |
1733 | ) { | 1447 | ) { |
1734 | const idsScope: ScopeOptions = { | 1448 | function getCount () { |
1735 | method: [ | 1449 | if (countVideos !== true) return Promise.resolve(undefined) |
1736 | ScopeNames.AVAILABLE_FOR_LIST_IDS, options | ||
1737 | ] | ||
1738 | } | ||
1739 | 1450 | ||
1740 | // Remove trending sort on count, because it uses a group by | 1451 | const countOptions = Object.assign({}, options, { isCount: true }) |
1741 | const countOptions = Object.assign({}, options, { trendingDays: undefined }) | 1452 | const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel, countOptions) |
1742 | const countQuery: CountOptions = Object.assign({}, query, { attributes: undefined, group: undefined }) | 1453 | |
1743 | const countScope: ScopeOptions = { | 1454 | return VideoModel.sequelize.query<any>(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT }) |
1744 | method: [ | 1455 | .then(rows => rows.length !== 0 ? rows[0].total : 0) |
1745 | ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions | ||
1746 | ] | ||
1747 | } | 1456 | } |
1748 | 1457 | ||
1749 | const [ count, rows ] = await Promise.all([ | 1458 | function getModels () { |
1750 | countVideos | 1459 | if (options.count === 0) return Promise.resolve([]) |
1751 | ? VideoModel.scope(countScope).count(countQuery) | 1460 | |
1752 | : Promise.resolve<number>(undefined), | 1461 | const { query, replacements, order } = buildListQuery(VideoModel, options) |
1462 | const queryModels = wrapForAPIResults(query, replacements, options, order) | ||
1753 | 1463 | ||
1754 | VideoModel.scope(idsScope) | 1464 | return VideoModel.sequelize.query<any>(queryModels, { replacements, type: QueryTypes.SELECT, nest: true }) |
1755 | .findAll(Object.assign({}, query, { raw: true })) | 1465 | .then(rows => VideoModel.buildAPIResult(rows)) |
1756 | .then(rows => rows.map(r => r.id)) | 1466 | } |
1757 | .then(ids => VideoModel.loadCompleteVideosForApi(ids, query, options)) | 1467 | |
1758 | ]) | 1468 | const [ count, rows ] = await Promise.all([ getCount(), getModels() ]) |
1759 | 1469 | ||
1760 | return { | 1470 | return { |
1761 | data: rows, | 1471 | data: rows, |
@@ -1763,37 +1473,113 @@ export class VideoModel extends Model<VideoModel> { | |||
1763 | } | 1473 | } |
1764 | } | 1474 | } |
1765 | 1475 | ||
1766 | private static loadCompleteVideosForApi (ids: number[], query: FindOptions, options: AvailableForListIDsOptions) { | 1476 | private static buildAPIResult (rows: any[]) { |
1767 | if (ids.length === 0) return [] | 1477 | const memo: { [ id: number ]: VideoModel } = {} |
1478 | |||
1479 | const thumbnailsDone = new Set<number>() | ||
1480 | const historyDone = new Set<number>() | ||
1481 | const videoFilesDone = new Set<number>() | ||
1482 | |||
1483 | const videos: VideoModel[] = [] | ||
1484 | |||
1485 | const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ] | ||
1486 | const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ] | ||
1487 | const serverKeys = [ 'id', 'host' ] | ||
1488 | const videoFileKeys = [ 'id', 'createdAt', 'updatedAt', 'resolution', 'size', 'extname', 'infoHash', 'fps', 'videoId' ] | ||
1489 | const videoKeys = [ | ||
1490 | 'id', | ||
1491 | 'uuid', | ||
1492 | 'name', | ||
1493 | 'category', | ||
1494 | 'licence', | ||
1495 | 'language', | ||
1496 | 'privacy', | ||
1497 | 'nsfw', | ||
1498 | 'description', | ||
1499 | 'support', | ||
1500 | 'duration', | ||
1501 | 'views', | ||
1502 | 'likes', | ||
1503 | 'dislikes', | ||
1504 | 'remote', | ||
1505 | 'url', | ||
1506 | 'commentsEnabled', | ||
1507 | 'downloadEnabled', | ||
1508 | 'waitTranscoding', | ||
1509 | 'state', | ||
1510 | 'publishedAt', | ||
1511 | 'originallyPublishedAt', | ||
1512 | 'channelId', | ||
1513 | 'createdAt', | ||
1514 | 'updatedAt' | ||
1515 | ] | ||
1768 | 1516 | ||
1769 | const secondQuery: FindOptions = { | 1517 | function buildActor (rowActor: any) { |
1770 | offset: 0, | 1518 | const avatarModel = rowActor.Avatar.id !== null |
1771 | limit: query.limit, | 1519 | ? new AvatarModel(pick(rowActor.Avatar, avatarKeys)) |
1772 | attributes: query.attributes, | 1520 | : null |
1773 | order: [ // Keep original order | 1521 | |
1774 | Sequelize.literal( | 1522 | const serverModel = rowActor.Server.id !== null |
1775 | ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ') | 1523 | ? new ServerModel(pick(rowActor.Server, serverKeys)) |
1776 | ) | 1524 | : null |
1777 | ] | ||
1778 | } | ||
1779 | 1525 | ||
1780 | const apiScope: (string | ScopeOptions)[] = [] | 1526 | const actorModel = new ActorModel(pick(rowActor, actorKeys)) |
1527 | actorModel.Avatar = avatarModel | ||
1528 | actorModel.Server = serverModel | ||
1781 | 1529 | ||
1782 | if (options.user) { | 1530 | return actorModel |
1783 | apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] }) | ||
1784 | } | 1531 | } |
1785 | 1532 | ||
1786 | apiScope.push({ | 1533 | for (const row of rows) { |
1787 | method: [ | 1534 | if (!memo[row.id]) { |
1788 | ScopeNames.FOR_API, { | 1535 | // Build Channel |
1789 | ids, | 1536 | const channel = row.VideoChannel |
1790 | withFiles: options.withFiles, | 1537 | const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ])) |
1791 | videoPlaylistId: options.videoPlaylistId | 1538 | channelModel.Actor = buildActor(channel.Actor) |
1792 | } as ForAPIOptions | 1539 | |
1793 | ] | 1540 | const account = row.VideoChannel.Account |
1794 | }) | 1541 | const accountModel = new AccountModel(pick(account, [ 'id', 'name' ])) |
1542 | accountModel.Actor = buildActor(account.Actor) | ||
1543 | |||
1544 | channelModel.Account = accountModel | ||
1545 | |||
1546 | const videoModel = new VideoModel(pick(row, videoKeys)) | ||
1547 | videoModel.VideoChannel = channelModel | ||
1795 | 1548 | ||
1796 | return VideoModel.scope(apiScope).findAll(secondQuery) | 1549 | videoModel.UserVideoHistories = [] |
1550 | videoModel.Thumbnails = [] | ||
1551 | videoModel.VideoFiles = [] | ||
1552 | |||
1553 | memo[row.id] = videoModel | ||
1554 | // Don't take object value to have a sorted array | ||
1555 | videos.push(videoModel) | ||
1556 | } | ||
1557 | |||
1558 | const videoModel = memo[row.id] | ||
1559 | |||
1560 | if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) { | ||
1561 | const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ])) | ||
1562 | videoModel.UserVideoHistories.push(historyModel) | ||
1563 | |||
1564 | historyDone.add(row.userVideoHistory.id) | ||
1565 | } | ||
1566 | |||
1567 | if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) { | ||
1568 | const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ])) | ||
1569 | videoModel.Thumbnails.push(thumbnailModel) | ||
1570 | |||
1571 | thumbnailsDone.add(row.Thumbnails.id) | ||
1572 | } | ||
1573 | |||
1574 | if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) { | ||
1575 | const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys)) | ||
1576 | videoModel.VideoFiles.push(videoFileModel) | ||
1577 | |||
1578 | videoFilesDone.add(row.VideoFiles.id) | ||
1579 | } | ||
1580 | } | ||
1581 | |||
1582 | return videos | ||
1797 | } | 1583 | } |
1798 | 1584 | ||
1799 | private static isPrivacyForFederation (privacy: VideoPrivacy) { | 1585 | private static isPrivacyForFederation (privacy: VideoPrivacy) { |
@@ -1803,23 +1589,23 @@ export class VideoModel extends Model<VideoModel> { | |||
1803 | } | 1589 | } |
1804 | 1590 | ||
1805 | static getCategoryLabel (id: number) { | 1591 | static getCategoryLabel (id: number) { |
1806 | return VIDEO_CATEGORIES[ id ] || 'Misc' | 1592 | return VIDEO_CATEGORIES[id] || 'Misc' |
1807 | } | 1593 | } |
1808 | 1594 | ||
1809 | static getLicenceLabel (id: number) { | 1595 | static getLicenceLabel (id: number) { |
1810 | return VIDEO_LICENCES[ id ] || 'Unknown' | 1596 | return VIDEO_LICENCES[id] || 'Unknown' |
1811 | } | 1597 | } |
1812 | 1598 | ||
1813 | static getLanguageLabel (id: string) { | 1599 | static getLanguageLabel (id: string) { |
1814 | return VIDEO_LANGUAGES[ id ] || 'Unknown' | 1600 | return VIDEO_LANGUAGES[id] || 'Unknown' |
1815 | } | 1601 | } |
1816 | 1602 | ||
1817 | static getPrivacyLabel (id: number) { | 1603 | static getPrivacyLabel (id: number) { |
1818 | return VIDEO_PRIVACIES[ id ] || 'Unknown' | 1604 | return VIDEO_PRIVACIES[id] || 'Unknown' |
1819 | } | 1605 | } |
1820 | 1606 | ||
1821 | static getStateLabel (id: number) { | 1607 | static getStateLabel (id: number) { |
1822 | return VIDEO_STATES[ id ] || 'Unknown' | 1608 | return VIDEO_STATES[id] || 'Unknown' |
1823 | } | 1609 | } |
1824 | 1610 | ||
1825 | isBlacklisted () { | 1611 | isBlacklisted () { |
@@ -1831,7 +1617,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1831 | this.VideoChannel.Account.isBlocked() | 1617 | this.VideoChannel.Account.isBlocked() |
1832 | } | 1618 | } |
1833 | 1619 | ||
1834 | getQualityFileBy <T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) { | 1620 | getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) { |
1835 | if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) { | 1621 | if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) { |
1836 | const file = fun(this.VideoFiles, file => file.resolution) | 1622 | const file = fun(this.VideoFiles, file => file.resolution) |
1837 | 1623 | ||
@@ -1849,15 +1635,15 @@ export class VideoModel extends Model<VideoModel> { | |||
1849 | return undefined | 1635 | return undefined |
1850 | } | 1636 | } |
1851 | 1637 | ||
1852 | getMaxQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { | 1638 | getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { |
1853 | return this.getQualityFileBy(maxBy) | 1639 | return this.getQualityFileBy(maxBy) |
1854 | } | 1640 | } |
1855 | 1641 | ||
1856 | getMinQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { | 1642 | getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { |
1857 | return this.getQualityFileBy(minBy) | 1643 | return this.getQualityFileBy(minBy) |
1858 | } | 1644 | } |
1859 | 1645 | ||
1860 | getWebTorrentFile <T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo { | 1646 | getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo { |
1861 | if (Array.isArray(this.VideoFiles) === false) return undefined | 1647 | if (Array.isArray(this.VideoFiles) === false) return undefined |
1862 | 1648 | ||
1863 | const file = this.VideoFiles.find(f => f.resolution === resolution) | 1649 | const file = this.VideoFiles.find(f => f.resolution === resolution) |
@@ -1893,6 +1679,10 @@ export class VideoModel extends Model<VideoModel> { | |||
1893 | return this.uuid + '.jpg' | 1679 | return this.uuid + '.jpg' |
1894 | } | 1680 | } |
1895 | 1681 | ||
1682 | hasPreview () { | ||
1683 | return !!this.getPreview() | ||
1684 | } | ||
1685 | |||
1896 | getPreview () { | 1686 | getPreview () { |
1897 | if (Array.isArray(this.Thumbnails) === false) return undefined | 1687 | if (Array.isArray(this.Thumbnails) === false) return undefined |
1898 | 1688 | ||
@@ -1980,8 +1770,8 @@ export class VideoModel extends Model<VideoModel> { | |||
1980 | } | 1770 | } |
1981 | 1771 | ||
1982 | this.VideoStreamingPlaylists = this.VideoStreamingPlaylists | 1772 | this.VideoStreamingPlaylists = this.VideoStreamingPlaylists |
1983 | .filter(s => s.type !== VideoStreamingPlaylistType.HLS) | 1773 | .filter(s => s.type !== VideoStreamingPlaylistType.HLS) |
1984 | .concat(toAdd) | 1774 | .concat(toAdd) |
1985 | } | 1775 | } |
1986 | 1776 | ||
1987 | removeFile (videoFile: MVideoFile, isRedundancy = false) { | 1777 | removeFile (videoFile: MVideoFile, isRedundancy = false) { |
@@ -2002,7 +1792,7 @@ export class VideoModel extends Model<VideoModel> { | |||
2002 | await remove(directoryPath) | 1792 | await remove(directoryPath) |
2003 | 1793 | ||
2004 | if (isRedundancy !== true) { | 1794 | if (isRedundancy !== true) { |
2005 | let streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo | 1795 | const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo |
2006 | streamingPlaylistWithFiles.Video = this | 1796 | streamingPlaylistWithFiles.Video = this |
2007 | 1797 | ||
2008 | if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) { | 1798 | if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) { |
@@ -2096,6 +1886,14 @@ export class VideoModel extends Model<VideoModel> { | |||
2096 | return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile) | 1886 | return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile) |
2097 | } | 1887 | } |
2098 | 1888 | ||
1889 | getVideoFileMetadataUrl (videoFile: MVideoFile, baseUrlHttp: string) { | ||
1890 | const path = '/api/v1/videos/' | ||
1891 | |||
1892 | return this.isOwned() | ||
1893 | ? baseUrlHttp + path + this.uuid + '/metadata/' + videoFile.id | ||
1894 | : videoFile.metadataUrl | ||
1895 | } | ||
1896 | |||
2099 | getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) { | 1897 | getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) { |
2100 | return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile) | 1898 | return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile) |
2101 | } | 1899 | } |