]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-query-builder.ts
Rewrite video list in raw SQL
[github/Chocobozzz/PeerTube.git] / server / models / video / video-query-builder.ts
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
7 export type BuildVideosQueryOptions = {
8 attributes?: string[]
9
10 serverAccountId: number
11 followerActorId: number
12 includeLocalVideos: boolean
13
14 count: number
15 start: number
16 sort: string
17
18 filter?: VideoFilter
19 categoryOneOf?: number[]
20 nsfw?: boolean
21 licenceOneOf?: number[]
22 languageOneOf?: string[]
23 tagsOneOf?: string[]
24 tagsAllOf?: string[]
25
26 withFiles?: boolean
27
28 accountId?: number
29 videoChannelId?: number
30
31 videoPlaylistId?: number
32
33 trendingDays?: number
34 user?: MUserAccountId
35 historyOfUser?: MUserId
36
37 startDate?: string // ISO 8601
38 endDate?: string // ISO 8601
39 originallyPublishedStartDate?: string
40 originallyPublishedEndDate?: string
41
42 durationMin?: number // seconds
43 durationMax?: number // seconds
44
45 search?: string
46
47 isCount?: boolean
48
49 group?: string
50 having?: string
51 }
52
53 function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) {
54 const and: string[] = []
55 const cte: string[] = []
56 const joins: string[] = []
57 const replacements: any = {}
58
59 let attributes: string[] = options.attributes || [ '"video"."id"' ]
60 let group = options.group || ''
61 const having = options.having || ''
62
63 joins.push(
64 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"' +
65 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"' +
66 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id"'
67 )
68
69 and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
70
71 if (options.serverAccountId) {
72 const blockerIds = [ options.serverAccountId ]
73 if (options.user) blockerIds.push(options.user.Account.id)
74
75 cte.push(
76 '"mutedAccount" AS (' +
77 ' SELECT "targetAccountId" AS "id"' +
78 ' FROM "accountBlocklist"' +
79 ' WHERE "accountId" IN (' + createSafeIn(model, blockerIds) + ')' +
80 ' UNION ALL' +
81 ' SELECT "account"."id" AS "id"' +
82 ' FROM account' +
83 ' INNER JOIN "actor" ON account."actorId" = actor.id' +
84 ' INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId"' +
85 ' WHERE "serverBlocklist"."accountId" IN (' + createSafeIn(model, blockerIds) + ')' +
86 ')'
87 )
88
89 cte.push(
90 '"mutedChannel" AS (' +
91 ' SELECT "videoChannel"."id"' +
92 ' FROM "videoChannel"' +
93 ' INNER JOIN "mutedAccount" ON "mutedAccount"."id" = "videoChannel"."accountId"' +
94 ' )'
95 )
96
97 and.push(
98 '"video"."channelId" NOT IN (SELECT "id" FROM "mutedChannel")'
99 )
100
101 replacements.videoChannelId = options.videoChannelId
102 }
103
104 // Only list public/published videos
105 if (!options.filter || options.filter !== 'all-local') {
106 and.push(
107 `("video"."state" = ${VideoState.PUBLISHED} OR ` +
108 `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
109 )
110
111 if (options.user) {
112 and.push(
113 `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
114 )
115 } else { // Or only public videos
116 and.push(
117 `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
118 )
119 }
120 }
121
122 if (options.videoPlaylistId) {
123 joins.push(
124 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
125 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
126 )
127
128 replacements.videoPlaylistId = options.videoPlaylistId
129 }
130
131 if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) {
132 and.push('"video"."remote" IS FALSE')
133 }
134
135 if (options.accountId) {
136 and.push('"account"."id" = :accountId')
137 replacements.accountId = options.accountId
138 }
139
140 if (options.videoChannelId) {
141 and.push('"videoChannel"."id" = :videoChannelId')
142 replacements.videoChannelId = options.videoChannelId
143 }
144
145 if (options.followerActorId) {
146 let query =
147 '(' +
148 ' EXISTS (' +
149 ' SELECT 1 FROM "videoShare" ' +
150 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
151 ' AND "actorFollowShare"."actorId" = :followerActorId WHERE "videoShare"."videoId" = "video"."id"' +
152 ' )' +
153 ' OR' +
154 ' EXISTS (' +
155 ' SELECT 1 from "actorFollow" ' +
156 ' WHERE "actorFollow"."targetActorId" = "actor"."id" AND "actorFollow"."actorId" = :followerActorId' +
157 ' )'
158
159 if (options.includeLocalVideos) {
160 query += ' OR "video"."remote" IS FALSE'
161 }
162
163 query += ')'
164
165 and.push(query)
166 replacements.followerActorId = options.followerActorId
167 }
168
169 if (options.withFiles === true) {
170 and.push('EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")')
171 }
172
173 if (options.tagsOneOf) {
174 const tagsOneOfLower = options.tagsOneOf.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, tagsOneOfLower) + ') ' +
181 ' AND "video"."id" = "videoTag"."videoId"' +
182 ')'
183 )
184 }
185
186 if (options.tagsAllOf) {
187 const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase())
188
189 and.push(
190 'EXISTS (' +
191 ' SELECT 1 FROM "videoTag" ' +
192 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
193 ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsAllOfLower) + ') ' +
194 ' AND "video"."id" = "videoTag"."videoId" ' +
195 ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
196 ')'
197 )
198 }
199
200 if (options.nsfw === true) {
201 and.push('"video"."nsfw" IS TRUE')
202 }
203
204 if (options.nsfw === false) {
205 and.push('"video"."nsfw" IS FALSE')
206 }
207
208 if (options.categoryOneOf) {
209 and.push('"video"."category" IN (:categoryOneOf)')
210 replacements.categoryOneOf = options.categoryOneOf
211 }
212
213 if (options.licenceOneOf) {
214 and.push('"video"."licence" IN (:licenceOneOf)')
215 replacements.licenceOneOf = options.licenceOneOf
216 }
217
218 if (options.languageOneOf) {
219 replacements.languageOneOf = options.languageOneOf.filter(l => l && l !== '_unknown')
220
221 let languagesQuery = '("video"."language" IN (:languageOneOf) OR '
222
223 if (options.languageOneOf.includes('_unknown')) {
224 languagesQuery += '"video"."language" IS NULL OR '
225 }
226
227 and.push(
228 languagesQuery +
229 ' EXISTS (' +
230 ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
231 ' IN (' + createSafeIn(model, options.languageOneOf) + ') AND ' +
232 ' "videoCaption"."videoId" = "video"."id"' +
233 ' )' +
234 ')'
235 )
236 }
237
238 // We don't exclude results in this if so if we do a count we don't need to add this complex clauses
239 if (options.trendingDays && options.isCount !== true) {
240 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays)
241
242 joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
243 replacements.viewsGteDate = viewsGteDate
244
245 group = 'GROUP BY "video"."id"'
246 }
247
248 if (options.historyOfUser) {
249 joins.push('INNER JOIN "userVideoHistory" on "video"."id" = "userVideoHistory"."videoId"')
250
251 and.push('"userVideoHistory"."userId" = :historyOfUser')
252 replacements.historyOfUser = options.historyOfUser
253 }
254
255 if (options.startDate) {
256 and.push('"video"."publishedAt" >= :startDate')
257 replacements.startDate = options.startDate
258 }
259
260 if (options.endDate) {
261 and.push('"video"."publishedAt" <= :endDate')
262 replacements.endDate = options.endDate
263 }
264
265 if (options.originallyPublishedStartDate) {
266 and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
267 replacements.originallyPublishedStartDate = options.originallyPublishedStartDate
268 }
269
270 if (options.originallyPublishedEndDate) {
271 and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
272 replacements.originallyPublishedEndDate = options.originallyPublishedEndDate
273 }
274
275 if (options.durationMin) {
276 and.push('"video"."duration" >= :durationMin')
277 replacements.durationMin = options.durationMin
278 }
279
280 if (options.durationMax) {
281 and.push('"video"."duration" <= :durationMax')
282 replacements.durationMax = options.durationMax
283 }
284
285 if (options.search) {
286 const escapedSearch = model.sequelize.escape(options.search)
287 const escapedLikeSearch = model.sequelize.escape('%' + options.search + '%')
288
289 let base = '(' +
290 ' lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
291 ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + ')) OR ' +
292 ' EXISTS (' +
293 ' SELECT 1 FROM "videoTag" ' +
294 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
295 ` WHERE lower("tag"."name") = ${escapedSearch} ` +
296 ' AND "video"."id" = "videoTag"."videoId"' +
297 ' )'
298
299 if (validator.isUUID(options.search)) {
300 base += ` OR "video"."uuid" = ${escapedSearch}`
301 }
302
303 base += ')'
304 and.push(base)
305
306 attributes.push(`similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity`)
307 } else {
308 attributes.push('0 as similarity')
309 }
310
311 if (options.isCount === true) attributes = [ 'COUNT(*) as "total"' ]
312
313 const cteString = cte.length !== 0
314 ? 'WITH ' + cte.join(', ') + ' '
315 : ''
316
317 let query = cteString +
318 'SELECT ' + attributes.join(', ') + ' ' +
319 'FROM "video" ' + joins.join(' ') + ' ' +
320 'WHERE ' + and.join(' AND ') + ' ' +
321 group + ' ' +
322 having + ' '
323
324 if (options.isCount !== true) {
325 const count = parseInt(options.count + '', 10)
326 const start = parseInt(options.start + '', 10)
327
328 query += buildOrder(model, options.sort) + ' ' +
329 'LIMIT ' + count + ' ' +
330 'OFFSET ' + start
331 }
332
333 return { query, replacements }
334 }
335
336 function buildOrder (model: typeof Model, value: string) {
337 const { direction, field } = buildDirectionAndField(value)
338 if (field.match(/^[a-zA-Z]+$/) === null) throw new Error('Invalid sort column ' + field)
339
340 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
341
342 if (field.toLowerCase() === 'trending') { // Sort by aggregation
343 return `ORDER BY COALESCE(SUM("videoView"."views"), 0) ${direction}, "video"."views" ${direction}`
344 }
345
346 let firstSort: string
347
348 if (field.toLowerCase() === 'match') { // Search
349 firstSort = '"similarity"'
350 } else {
351 firstSort = `"video"."${field}"`
352 }
353
354 return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
355 }
356
357 export {
358 buildListQuery
359 }