]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/sql/video/videos-id-list-query-builder.ts
Don't inject untrusted input
[github/Chocobozzz/PeerTube.git] / server / models / video / sql / video / videos-id-list-query-builder.ts
CommitLineData
2ec349aa 1import { Sequelize, Transaction } from 'sequelize'
e5dbd508
C
2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc'
29837f88 4import { WEBSERVER } from '@server/initializers/constants'
bae61627 5import { buildDirectionAndField, createSafeIn, parseRowCountResult } from '@server/models/utils'
e5dbd508 6import { MUserAccountId, MUserId } from '@server/types/models'
2760b454 7import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
156c44c8 8import { AbstractRunQuery } from '../../../shared/abstract-run-query'
4638cd71 9import { forceNumber } from '@shared/core-utils'
e5dbd508 10
1d43c3a6
C
11/**
12 *
13 * Build videos list SQL query to fetch rows
14 *
15 */
16
2760b454
C
17export type DisplayOnlyForFollowerOptions = {
18 actorId: number
19 orLocalVideos: boolean
20}
21
e5dbd508
C
22export type BuildVideosListQueryOptions = {
23 attributes?: string[]
24
2760b454
C
25 serverAccountIdForBlock: number
26
27 displayOnlyForFollower: DisplayOnlyForFollowerOptions
e5dbd508
C
28
29 count: number
30 start: number
31 sort: string
32
33 nsfw?: boolean
29837f88 34 host?: string
e5dbd508 35 isLive?: boolean
2760b454
C
36 isLocal?: boolean
37 include?: VideoInclude
e5dbd508
C
38
39 categoryOneOf?: number[]
40 licenceOneOf?: number[]
41 languageOneOf?: string[]
42 tagsOneOf?: string[]
43 tagsAllOf?: string[]
527a52ac 44 privacyOneOf?: VideoPrivacy[]
e5dbd508 45
fbd67e7f
C
46 uuids?: string[]
47
3c10840f 48 hasFiles?: boolean
d324756e
C
49 hasHLSFiles?: boolean
50 hasWebtorrentFiles?: boolean
e5dbd508
C
51
52 accountId?: number
53 videoChannelId?: number
54
55 videoPlaylistId?: number
56
57 trendingAlgorithm?: string // best, hot, or any other algorithm implemented
58 trendingDays?: number
59
60 user?: MUserAccountId
61 historyOfUser?: MUserId
62
63 startDate?: string // ISO 8601
64 endDate?: string // ISO 8601
65 originallyPublishedStartDate?: string
66 originallyPublishedEndDate?: string
67
68 durationMin?: number // seconds
69 durationMax?: number // seconds
70
71 search?: string
72
73 isCount?: boolean
74
75 group?: string
76 having?: string
2ec349aa
C
77
78 transaction?: Transaction
79 logging?: boolean
e5dbd508
C
80}
81
7e7d8e48 82export class VideosIdListQueryBuilder extends AbstractRunQuery {
d9bf974f
C
83 protected replacements: any = {}
84
e5dbd508 85 private attributes: string[]
d9bf974f 86 private joins: string[] = []
e5dbd508 87
e5dbd508 88 private readonly and: string[] = []
e5dbd508
C
89
90 private readonly cte: string[] = []
91
92 private group = ''
93 private having = ''
94
95 private sort = ''
96 private limit = ''
97 private offset = ''
98
99 constructor (protected readonly sequelize: Sequelize) {
156c44c8 100 super(sequelize)
e5dbd508
C
101 }
102
103 queryVideoIds (options: BuildVideosListQueryOptions) {
104 this.buildIdsListQuery(options)
105
106 return this.runQuery()
107 }
108
109 countVideoIds (countOptions: BuildVideosListQueryOptions): Promise<number> {
110 this.buildIdsListQuery(countOptions)
111
bae61627 112 return this.runQuery().then(rows => parseRowCountResult(rows))
e5dbd508
C
113 }
114
7e7d8e48 115 getQuery (options: BuildVideosListQueryOptions) {
e5dbd508 116 this.buildIdsListQuery(options)
2760b454 117
e5dbd508
C
118 return { query: this.query, sort: this.sort, replacements: this.replacements }
119 }
120
121 private buildIdsListQuery (options: BuildVideosListQueryOptions) {
122 this.attributes = options.attributes || [ '"video"."id"' ]
123
124 if (options.group) this.group = options.group
125 if (options.having) this.having = options.having
126
127 this.joins = this.joins.concat([
128 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"',
129 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"',
130 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"'
131 ])
132
2760b454
C
133 if (!(options.include & VideoInclude.BLACKLISTED)) {
134 this.whereNotBlacklisted()
135 }
e5dbd508 136
2760b454
C
137 if (options.serverAccountIdForBlock && !(options.include & VideoInclude.BLOCKED_OWNER)) {
138 this.whereNotBlocked(options.serverAccountIdForBlock, options.user)
e5dbd508
C
139 }
140
2760b454
C
141 // Only list published videos
142 if (!(options.include & VideoInclude.NOT_PUBLISHED_STATE)) {
143 this.whereStateAvailable()
144 }
145
e5dbd508
C
146 if (options.videoPlaylistId) {
147 this.joinPlaylist(options.videoPlaylistId)
148 }
149
2760b454
C
150 if (exists(options.isLocal)) {
151 this.whereLocal(options.isLocal)
e5dbd508
C
152 }
153
29837f88
C
154 if (options.host) {
155 this.whereHost(options.host)
156 }
157
e5dbd508
C
158 if (options.accountId) {
159 this.whereAccountId(options.accountId)
160 }
161
162 if (options.videoChannelId) {
163 this.whereChannelId(options.videoChannelId)
164 }
165
2760b454
C
166 if (options.displayOnlyForFollower) {
167 this.whereFollowerActorId(options.displayOnlyForFollower)
e5dbd508
C
168 }
169
3c10840f 170 if (options.hasFiles === true) {
e5dbd508
C
171 this.whereFileExists()
172 }
173
d324756e
C
174 if (exists(options.hasWebtorrentFiles)) {
175 this.whereWebTorrentFileExists(options.hasWebtorrentFiles)
176 }
177
178 if (exists(options.hasHLSFiles)) {
179 this.whereHLSFileExists(options.hasHLSFiles)
180 }
181
e5dbd508
C
182 if (options.tagsOneOf) {
183 this.whereTagsOneOf(options.tagsOneOf)
184 }
185
186 if (options.tagsAllOf) {
187 this.whereTagsAllOf(options.tagsAllOf)
188 }
189
527a52ac
C
190 if (options.privacyOneOf) {
191 this.wherePrivacyOneOf(options.privacyOneOf)
192 } else {
4b3145a7 193 // Only list videos with the appropriate privacy
527a52ac
C
194 this.wherePrivacyAvailable(options.user)
195 }
196
fbd67e7f
C
197 if (options.uuids) {
198 this.whereUUIDs(options.uuids)
199 }
200
e5dbd508
C
201 if (options.nsfw === true) {
202 this.whereNSFW()
203 } else if (options.nsfw === false) {
204 this.whereSFW()
205 }
206
207 if (options.isLive === true) {
208 this.whereLive()
209 } else if (options.isLive === false) {
210 this.whereVOD()
211 }
212
213 if (options.categoryOneOf) {
214 this.whereCategoryOneOf(options.categoryOneOf)
215 }
216
217 if (options.licenceOneOf) {
218 this.whereLicenceOneOf(options.licenceOneOf)
219 }
220
221 if (options.languageOneOf) {
222 this.whereLanguageOneOf(options.languageOneOf)
223 }
224
225 // We don't exclude results in this so if we do a count we don't need to add this complex clause
226 if (options.isCount !== true) {
227 if (options.trendingDays) {
228 this.groupForTrending(options.trendingDays)
229 } else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) {
230 this.groupForHotOrBest(options.trendingAlgorithm, options.user)
231 }
232 }
233
234 if (options.historyOfUser) {
235 this.joinHistory(options.historyOfUser.id)
236 }
237
238 if (options.startDate) {
239 this.whereStartDate(options.startDate)
240 }
241
242 if (options.endDate) {
243 this.whereEndDate(options.endDate)
244 }
245
246 if (options.originallyPublishedStartDate) {
247 this.whereOriginallyPublishedStartDate(options.originallyPublishedStartDate)
248 }
249
250 if (options.originallyPublishedEndDate) {
251 this.whereOriginallyPublishedEndDate(options.originallyPublishedEndDate)
252 }
253
254 if (options.durationMin) {
255 this.whereDurationMin(options.durationMin)
256 }
257
258 if (options.durationMax) {
259 this.whereDurationMax(options.durationMax)
260 }
261
262 this.whereSearch(options.search)
263
264 if (options.isCount === true) {
265 this.setCountAttribute()
266 } else {
267 if (exists(options.sort)) {
268 this.setSort(options.sort)
269 }
270
271 if (exists(options.count)) {
272 this.setLimit(options.count)
273 }
274
275 if (exists(options.start)) {
276 this.setOffset(options.start)
277 }
278 }
279
280 const cteString = this.cte.length !== 0
281 ? `WITH ${this.cte.join(', ')} `
282 : ''
283
284 this.query = cteString +
285 'SELECT ' + this.attributes.join(', ') + ' ' +
286 'FROM "video" ' + this.joins.join(' ') + ' ' +
287 'WHERE ' + this.and.join(' AND ') + ' ' +
288 this.group + ' ' +
289 this.having + ' ' +
290 this.sort + ' ' +
291 this.limit + ' ' +
292 this.offset
293 }
294
295 private setCountAttribute () {
296 this.attributes = [ 'COUNT(*) as "total"' ]
297 }
298
299 private joinHistory (userId: number) {
300 this.joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"')
301
302 this.and.push('"userVideoHistory"."userId" = :historyOfUser')
303
304 this.replacements.historyOfUser = userId
305 }
306
307 private joinPlaylist (playlistId: number) {
308 this.joins.push(
309 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
310 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
311 )
312
313 this.replacements.videoPlaylistId = playlistId
314 }
315
2760b454 316 private whereStateAvailable () {
e5dbd508
C
317 this.and.push(
318 `("video"."state" = ${VideoState.PUBLISHED} OR ` +
319 `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
320 )
2760b454 321 }
e5dbd508 322
2760b454 323 private wherePrivacyAvailable (user?: MUserAccountId) {
e5dbd508
C
324 if (user) {
325 this.and.push(
326 `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
327 )
328 } else { // Or only public videos
329 this.and.push(
330 `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
331 )
332 }
333 }
334
2760b454
C
335 private whereLocal (isLocal: boolean) {
336 const isRemote = isLocal ? 'FALSE' : 'TRUE'
337
338 this.and.push('"video"."remote" IS ' + isRemote)
e5dbd508
C
339 }
340
29837f88
C
341 private whereHost (host: string) {
342 // Local instance
343 if (host === WEBSERVER.HOST) {
344 this.and.push('"accountActor"."serverId" IS NULL')
345 return
346 }
347
348 this.joins.push('INNER JOIN "server" ON "server"."id" = "accountActor"."serverId"')
349
350 this.and.push('"server"."host" = :host')
351 this.replacements.host = host
352 }
353
e5dbd508
C
354 private whereAccountId (accountId: number) {
355 this.and.push('"account"."id" = :accountId')
356 this.replacements.accountId = accountId
357 }
358
359 private whereChannelId (channelId: number) {
360 this.and.push('"videoChannel"."id" = :videoChannelId')
361 this.replacements.videoChannelId = channelId
362 }
363
2760b454 364 private whereFollowerActorId (options: { actorId: number, orLocalVideos: boolean }) {
e5dbd508
C
365 let query =
366 '(' +
4d029ef8 367 ' EXISTS (' + // Videos shared by actors we follow
e5dbd508
C
368 ' SELECT 1 FROM "videoShare" ' +
369 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
370 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
371 ' WHERE "videoShare"."videoId" = "video"."id"' +
372 ' )' +
373 ' OR' +
84845992 374 ' EXISTS (' + // Videos published by channels or accounts we follow
e5dbd508 375 ' SELECT 1 from "actorFollow" ' +
84845992
C
376 ' WHERE ("actorFollow"."targetActorId" = "account"."actorId" OR "actorFollow"."targetActorId" = "videoChannel"."actorId") ' +
377 ' AND "actorFollow"."actorId" = :followerActorId ' +
e5dbd508
C
378 ' AND "actorFollow"."state" = \'accepted\'' +
379 ' )'
380
2760b454 381 if (options.orLocalVideos) {
e5dbd508
C
382 query += ' OR "video"."remote" IS FALSE'
383 }
384
385 query += ')'
386
387 this.and.push(query)
2760b454 388 this.replacements.followerActorId = options.actorId
e5dbd508
C
389 }
390
391 private whereFileExists () {
d324756e
C
392 this.and.push(`(${this.buildWebTorrentFileExistsQuery(true)} OR ${this.buildHLSFileExistsQuery(true)})`)
393 }
394
395 private whereWebTorrentFileExists (exists: boolean) {
396 this.and.push(this.buildWebTorrentFileExistsQuery(exists))
397 }
398
399 private whereHLSFileExists (exists: boolean) {
400 this.and.push(this.buildHLSFileExistsQuery(exists))
401 }
402
403 private buildWebTorrentFileExistsQuery (exists: boolean) {
404 const prefix = exists ? '' : 'NOT '
405
406 return prefix + 'EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")'
407 }
408
409 private buildHLSFileExistsQuery (exists: boolean) {
410 const prefix = exists ? '' : 'NOT '
411
412 return prefix + 'EXISTS (' +
413 ' SELECT 1 FROM "videoStreamingPlaylist" ' +
414 ' INNER JOIN "videoFile" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ' +
415 ' WHERE "videoStreamingPlaylist"."videoId" = "video"."id"' +
416 ')'
e5dbd508
C
417 }
418
419 private whereTagsOneOf (tagsOneOf: string[]) {
420 const tagsOneOfLower = tagsOneOf.map(t => t.toLowerCase())
421
422 this.and.push(
423 'EXISTS (' +
424 ' SELECT 1 FROM "videoTag" ' +
425 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
426 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsOneOfLower) + ') ' +
427 ' AND "video"."id" = "videoTag"."videoId"' +
428 ')'
429 )
430 }
431
432 private whereTagsAllOf (tagsAllOf: string[]) {
433 const tagsAllOfLower = tagsAllOf.map(t => t.toLowerCase())
434
435 this.and.push(
436 'EXISTS (' +
437 ' SELECT 1 FROM "videoTag" ' +
438 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
439 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsAllOfLower) + ') ' +
440 ' AND "video"."id" = "videoTag"."videoId" ' +
441 ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
442 ')'
443 )
444 }
445
527a52ac
C
446 private wherePrivacyOneOf (privacyOneOf: VideoPrivacy[]) {
447 this.and.push('"video"."privacy" IN (:privacyOneOf)')
448 this.replacements.privacyOneOf = privacyOneOf
449 }
450
fbd67e7f
C
451 private whereUUIDs (uuids: string[]) {
452 this.and.push('"video"."uuid" IN (' + createSafeIn(this.sequelize, uuids) + ')')
453 }
454
e5dbd508
C
455 private whereCategoryOneOf (categoryOneOf: number[]) {
456 this.and.push('"video"."category" IN (:categoryOneOf)')
457 this.replacements.categoryOneOf = categoryOneOf
458 }
459
460 private whereLicenceOneOf (licenceOneOf: number[]) {
461 this.and.push('"video"."licence" IN (:licenceOneOf)')
462 this.replacements.licenceOneOf = licenceOneOf
463 }
464
465 private whereLanguageOneOf (languageOneOf: string[]) {
466 const languages = languageOneOf.filter(l => l && l !== '_unknown')
467 const languagesQueryParts: string[] = []
468
469 if (languages.length !== 0) {
470 languagesQueryParts.push('"video"."language" IN (:languageOneOf)')
471 this.replacements.languageOneOf = languages
472
473 languagesQueryParts.push(
474 'EXISTS (' +
475 ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
476 ' IN (' + createSafeIn(this.sequelize, languages) + ') AND ' +
477 ' "videoCaption"."videoId" = "video"."id"' +
478 ')'
479 )
480 }
481
482 if (languageOneOf.includes('_unknown')) {
483 languagesQueryParts.push('"video"."language" IS NULL')
484 }
485
486 if (languagesQueryParts.length !== 0) {
487 this.and.push('(' + languagesQueryParts.join(' OR ') + ')')
488 }
489 }
490
491 private whereNSFW () {
492 this.and.push('"video"."nsfw" IS TRUE')
493 }
494
495 private whereSFW () {
496 this.and.push('"video"."nsfw" IS FALSE')
497 }
498
499 private whereLive () {
500 this.and.push('"video"."isLive" IS TRUE')
501 }
502
503 private whereVOD () {
504 this.and.push('"video"."isLive" IS FALSE')
505 }
506
507 private whereNotBlocked (serverAccountId: number, user?: MUserAccountId) {
508 const blockerIds = [ serverAccountId ]
509 if (user) blockerIds.push(user.Account.id)
510
511 const inClause = createSafeIn(this.sequelize, blockerIds)
512
513 this.and.push(
514 'NOT EXISTS (' +
515 ' SELECT 1 FROM "accountBlocklist" ' +
516 ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' +
517 ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' +
518 ')' +
519 'AND NOT EXISTS (' +
520 ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' +
521 ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' +
522 ')'
523 )
524 }
525
526 private whereSearch (search?: string) {
527 if (!search) {
528 this.attributes.push('0 as similarity')
529 return
530 }
531
532 const escapedSearch = this.sequelize.escape(search)
533 const escapedLikeSearch = this.sequelize.escape('%' + search + '%')
534
535 this.cte.push(
536 '"trigramSearch" AS (' +
537 ' SELECT "video"."id", ' +
538 ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` +
539 ' FROM "video" ' +
540 ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
541 ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
542 ')'
543 )
544
545 this.joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
546
547 let base = '(' +
548 ' "trigramSearch"."id" IS NOT NULL OR ' +
549 ' EXISTS (' +
550 ' SELECT 1 FROM "videoTag" ' +
551 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
cd8f207a 552 ` WHERE lower("tag"."name") = lower(${escapedSearch}) ` +
e5dbd508
C
553 ' AND "video"."id" = "videoTag"."videoId"' +
554 ' )'
555
556 if (validator.isUUID(search)) {
557 base += ` OR "video"."uuid" = ${escapedSearch}`
558 }
559
560 base += ')'
561
562 this.and.push(base)
563 this.attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`)
564 }
565
566 private whereNotBlacklisted () {
567 this.and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
568 }
569
570 private whereStartDate (startDate: string) {
571 this.and.push('"video"."publishedAt" >= :startDate')
572 this.replacements.startDate = startDate
573 }
574
575 private whereEndDate (endDate: string) {
576 this.and.push('"video"."publishedAt" <= :endDate')
577 this.replacements.endDate = endDate
578 }
579
580 private whereOriginallyPublishedStartDate (startDate: string) {
581 this.and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
582 this.replacements.originallyPublishedStartDate = startDate
583 }
584
585 private whereOriginallyPublishedEndDate (endDate: string) {
586 this.and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
587 this.replacements.originallyPublishedEndDate = endDate
588 }
589
590 private whereDurationMin (durationMin: number) {
591 this.and.push('"video"."duration" >= :durationMin')
592 this.replacements.durationMin = durationMin
593 }
594
595 private whereDurationMax (durationMax: number) {
596 this.and.push('"video"."duration" <= :durationMax')
597 this.replacements.durationMax = durationMax
598 }
599
600 private groupForTrending (trendingDays: number) {
601 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
602
603 this.joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
604 this.replacements.viewsGteDate = viewsGteDate
605
606 this.attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
607
608 this.group = 'GROUP BY "video"."id"'
609 }
610
611 private groupForHotOrBest (trendingAlgorithm: string, user?: MUserAccountId) {
612 /**
613 * "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
614 * with fixed weights only applied to their log values.
615 *
616 * This algorithm gives little chance for an old video to have a good score,
617 * for which recent spikes in interactions could be a sign of "hotness" and
618 * justify a better score. However there are multiple ways to achieve that
619 * goal, which is left for later. Yes, this is a TODO :)
620 *
621 * notes:
622 * - weights and base score are in number of half-days.
623 * - all comments are counted, regardless of being written by the video author or not
624 * see https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L47-L58
625 * - we have less interactions than on reddit, so multiply weights by an arbitrary factor
626 */
627 const weights = {
628 like: 3 * 50,
629 dislike: -3 * 50,
630 view: Math.floor((1 / 3) * 50),
631 comment: 2 * 50, // a comment takes more time than a like to do, but can be done multiple times
632 history: -2 * 50
633 }
634
635 this.joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
636
637 let attribute =
638 `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
639 `+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
640 `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
641 `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
642 '+ (SELECT (EXTRACT(epoch FROM "video"."publishedAt") - 1446156582) / 47000) ' // base score (in number of half-days)
643
644 if (trendingAlgorithm === 'best' && user) {
645 this.joins.push(
646 'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser'
647 )
648 this.replacements.bestUser = user.id
649
650 attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} `
651 }
652
653 attribute += 'AS "score"'
654 this.attributes.push(attribute)
655
656 this.group = 'GROUP BY "video"."id"'
657 }
658
659 private setSort (sort: string) {
660 if (sort === '-originallyPublishedAt' || sort === 'originallyPublishedAt') {
661 this.attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
662 }
663
664 this.sort = this.buildOrder(sort)
665 }
666
667 private buildOrder (value: string) {
668 const { direction, field } = buildDirectionAndField(value)
669 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
670
671 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
672
673 if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
674 return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
675 }
676
677 let firstSort: string
678
679 if (field.toLowerCase() === 'match') { // Search
680 firstSort = '"similarity"'
681 } else if (field === 'originallyPublishedAt') {
682 firstSort = '"publishedAtForOrder"'
683 } else if (field.includes('.')) {
684 firstSort = field
685 } else {
686 firstSort = `"video"."${field}"`
687 }
688
689 return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
690 }
691
692 private setLimit (countArg: number) {
4638cd71 693 const count = forceNumber(countArg)
e5dbd508
C
694 this.limit = `LIMIT ${count}`
695 }
696
697 private setOffset (startArg: number) {
4638cd71 698 const start = forceNumber(startArg)
e5dbd508
C
699 this.offset = `OFFSET ${start}`
700 }
701}