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