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