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