video-query-builder.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  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/types/models'
  5. import validator from 'validator'
  6. import { exists } from '@server/helpers/custom-validators/misc'
  7. export type BuildVideosQueryOptions = {
  8. attributes?: string[]
  9. serverAccountId: number
  10. followerActorId: number
  11. includeLocalVideos: boolean
  12. count: number
  13. start: number
  14. sort: string
  15. filter?: VideoFilter
  16. categoryOneOf?: number[]
  17. nsfw?: boolean
  18. licenceOneOf?: number[]
  19. languageOneOf?: string[]
  20. tagsOneOf?: string[]
  21. tagsAllOf?: string[]
  22. withFiles?: boolean
  23. accountId?: number
  24. videoChannelId?: number
  25. videoPlaylistId?: number
  26. trendingDays?: number
  27. user?: MUserAccountId
  28. historyOfUser?: MUserId
  29. startDate?: string // ISO 8601
  30. endDate?: string // ISO 8601
  31. originallyPublishedStartDate?: string
  32. originallyPublishedEndDate?: string
  33. durationMin?: number // seconds
  34. durationMax?: number // seconds
  35. search?: string
  36. isCount?: boolean
  37. group?: string
  38. having?: string
  39. }
  40. function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) {
  41. const and: string[] = []
  42. const joins: string[] = []
  43. const replacements: any = {}
  44. const cte: string[] = []
  45. let attributes: string[] = options.attributes || [ '"video"."id"' ]
  46. let group = options.group || ''
  47. const having = options.having || ''
  48. joins.push(
  49. 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"' +
  50. 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"' +
  51. 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"'
  52. )
  53. and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
  54. if (options.serverAccountId) {
  55. const blockerIds = [ options.serverAccountId ]
  56. if (options.user) blockerIds.push(options.user.Account.id)
  57. const inClause = createSafeIn(model, blockerIds)
  58. and.push(
  59. 'NOT EXISTS (' +
  60. ' SELECT 1 FROM "accountBlocklist" ' +
  61. ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' +
  62. ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' +
  63. ')' +
  64. 'AND NOT EXISTS (' +
  65. ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' +
  66. ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' +
  67. ')'
  68. )
  69. }
  70. // Only list public/published videos
  71. if (!options.filter || (options.filter !== 'all-local' && options.filter !== 'all')) {
  72. and.push(
  73. `("video"."state" = ${VideoState.PUBLISHED} OR ` +
  74. `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
  75. )
  76. if (options.user) {
  77. and.push(
  78. `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
  79. )
  80. } else { // Or only public videos
  81. and.push(
  82. `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
  83. )
  84. }
  85. }
  86. if (options.videoPlaylistId) {
  87. joins.push(
  88. 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
  89. 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
  90. )
  91. replacements.videoPlaylistId = options.videoPlaylistId
  92. }
  93. if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) {
  94. and.push('"video"."remote" IS FALSE')
  95. }
  96. if (options.accountId) {
  97. and.push('"account"."id" = :accountId')
  98. replacements.accountId = options.accountId
  99. }
  100. if (options.videoChannelId) {
  101. and.push('"videoChannel"."id" = :videoChannelId')
  102. replacements.videoChannelId = options.videoChannelId
  103. }
  104. if (options.followerActorId) {
  105. let query =
  106. '(' +
  107. ' EXISTS (' +
  108. ' SELECT 1 FROM "videoShare" ' +
  109. ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
  110. ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
  111. ' WHERE "videoShare"."videoId" = "video"."id"' +
  112. ' )' +
  113. ' OR' +
  114. ' EXISTS (' +
  115. ' SELECT 1 from "actorFollow" ' +
  116. ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
  117. ' AND "actorFollow"."state" = \'accepted\'' +
  118. ' )'
  119. if (options.includeLocalVideos) {
  120. query += ' OR "video"."remote" IS FALSE'
  121. }
  122. query += ')'
  123. and.push(query)
  124. replacements.followerActorId = options.followerActorId
  125. }
  126. if (options.withFiles === true) {
  127. and.push(
  128. '(' +
  129. ' EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id") ' +
  130. ' OR EXISTS (' +
  131. ' SELECT 1 FROM "videoStreamingPlaylist" ' +
  132. ' INNER JOIN "videoFile" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ' +
  133. ' WHERE "videoStreamingPlaylist"."videoId" = "video"."id"' +
  134. ' )' +
  135. ')'
  136. )
  137. }
  138. if (options.tagsOneOf) {
  139. const tagsOneOfLower = options.tagsOneOf.map(t => t.toLowerCase())
  140. and.push(
  141. 'EXISTS (' +
  142. ' SELECT 1 FROM "videoTag" ' +
  143. ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
  144. ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsOneOfLower) + ') ' +
  145. ' AND "video"."id" = "videoTag"."videoId"' +
  146. ')'
  147. )
  148. }
  149. if (options.tagsAllOf) {
  150. const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase())
  151. and.push(
  152. 'EXISTS (' +
  153. ' SELECT 1 FROM "videoTag" ' +
  154. ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
  155. ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsAllOfLower) + ') ' +
  156. ' AND "video"."id" = "videoTag"."videoId" ' +
  157. ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
  158. ')'
  159. )
  160. }
  161. if (options.nsfw === true) {
  162. and.push('"video"."nsfw" IS TRUE')
  163. }
  164. if (options.nsfw === false) {
  165. and.push('"video"."nsfw" IS FALSE')
  166. }
  167. if (options.categoryOneOf) {
  168. and.push('"video"."category" IN (:categoryOneOf)')
  169. replacements.categoryOneOf = options.categoryOneOf
  170. }
  171. if (options.licenceOneOf) {
  172. and.push('"video"."licence" IN (:licenceOneOf)')
  173. replacements.licenceOneOf = options.licenceOneOf
  174. }
  175. if (options.languageOneOf) {
  176. const languages = options.languageOneOf.filter(l => l && l !== '_unknown')
  177. const languagesQueryParts: string[] = []
  178. if (languages.length !== 0) {
  179. languagesQueryParts.push('"video"."language" IN (:languageOneOf)')
  180. replacements.languageOneOf = languages
  181. languagesQueryParts.push(
  182. 'EXISTS (' +
  183. ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
  184. ' IN (' + createSafeIn(model, languages) + ') AND ' +
  185. ' "videoCaption"."videoId" = "video"."id"' +
  186. ')'
  187. )
  188. }
  189. if (options.languageOneOf.includes('_unknown')) {
  190. languagesQueryParts.push('"video"."language" IS NULL')
  191. }
  192. if (languagesQueryParts.length !== 0) {
  193. and.push('(' + languagesQueryParts.join(' OR ') + ')')
  194. }
  195. }
  196. // We don't exclude results in this if so if we do a count we don't need to add this complex clauses
  197. if (options.trendingDays && options.isCount !== true) {
  198. const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays)
  199. joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
  200. replacements.viewsGteDate = viewsGteDate
  201. attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "videoViewsSum"')
  202. group = 'GROUP BY "video"."id"'
  203. }
  204. if (options.historyOfUser) {
  205. joins.push('INNER JOIN "userVideoHistory" on "video"."id" = "userVideoHistory"."videoId"')
  206. and.push('"userVideoHistory"."userId" = :historyOfUser')
  207. replacements.historyOfUser = options.historyOfUser.id
  208. }
  209. if (options.startDate) {
  210. and.push('"video"."publishedAt" >= :startDate')
  211. replacements.startDate = options.startDate
  212. }
  213. if (options.endDate) {
  214. and.push('"video"."publishedAt" <= :endDate')
  215. replacements.endDate = options.endDate
  216. }
  217. if (options.originallyPublishedStartDate) {
  218. and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
  219. replacements.originallyPublishedStartDate = options.originallyPublishedStartDate
  220. }
  221. if (options.originallyPublishedEndDate) {
  222. and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
  223. replacements.originallyPublishedEndDate = options.originallyPublishedEndDate
  224. }
  225. if (options.durationMin) {
  226. and.push('"video"."duration" >= :durationMin')
  227. replacements.durationMin = options.durationMin
  228. }
  229. if (options.durationMax) {
  230. and.push('"video"."duration" <= :durationMax')
  231. replacements.durationMax = options.durationMax
  232. }
  233. if (options.search) {
  234. const escapedSearch = model.sequelize.escape(options.search)
  235. const escapedLikeSearch = model.sequelize.escape('%' + options.search + '%')
  236. cte.push(
  237. '"trigramSearch" AS (' +
  238. ' SELECT "video"."id", ' +
  239. ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` +
  240. ' FROM "video" ' +
  241. ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
  242. ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
  243. ')'
  244. )
  245. joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
  246. let base = '(' +
  247. ' "trigramSearch"."id" IS NOT NULL OR ' +
  248. ' EXISTS (' +
  249. ' SELECT 1 FROM "videoTag" ' +
  250. ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
  251. ` WHERE lower("tag"."name") = ${escapedSearch} ` +
  252. ' AND "video"."id" = "videoTag"."videoId"' +
  253. ' )'
  254. if (validator.isUUID(options.search)) {
  255. base += ` OR "video"."uuid" = ${escapedSearch}`
  256. }
  257. base += ')'
  258. and.push(base)
  259. attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`)
  260. } else {
  261. attributes.push('0 as similarity')
  262. }
  263. if (options.isCount === true) attributes = [ 'COUNT(*) as "total"' ]
  264. let suffix = ''
  265. let order = ''
  266. if (options.isCount !== true) {
  267. if (exists(options.sort)) {
  268. if (options.sort === '-originallyPublishedAt' || options.sort === 'originallyPublishedAt') {
  269. attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
  270. }
  271. order = buildOrder(options.sort)
  272. suffix += `${order} `
  273. }
  274. if (exists(options.count)) {
  275. const count = parseInt(options.count + '', 10)
  276. suffix += `LIMIT ${count} `
  277. }
  278. if (exists(options.start)) {
  279. const start = parseInt(options.start + '', 10)
  280. suffix += `OFFSET ${start} `
  281. }
  282. }
  283. const cteString = cte.length !== 0
  284. ? `WITH ${cte.join(', ')} `
  285. : ''
  286. const query = cteString +
  287. 'SELECT ' + attributes.join(', ') + ' ' +
  288. 'FROM "video" ' + joins.join(' ') + ' ' +
  289. 'WHERE ' + and.join(' AND ') + ' ' +
  290. group + ' ' +
  291. having + ' ' +
  292. suffix
  293. return { query, replacements, order }
  294. }
  295. function buildOrder (value: string) {
  296. const { direction, field } = buildDirectionAndField(value)
  297. if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
  298. if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
  299. if (field.toLowerCase() === 'trending') { // Sort by aggregation
  300. return `ORDER BY "videoViewsSum" ${direction}, "video"."views" ${direction}`
  301. }
  302. let firstSort: string
  303. if (field.toLowerCase() === 'match') { // Search
  304. firstSort = '"similarity"'
  305. } else if (field === 'originallyPublishedAt') {
  306. firstSort = '"publishedAtForOrder"'
  307. } else if (field.includes('.')) {
  308. firstSort = field
  309. } else {
  310. firstSort = `"video"."${field}"`
  311. }
  312. return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
  313. }
  314. function wrapForAPIResults (baseQuery: string, replacements: any, options: BuildVideosQueryOptions, order: string) {
  315. const attributes = {
  316. '"video".*': '',
  317. '"VideoChannel"."id"': '"VideoChannel.id"',
  318. '"VideoChannel"."name"': '"VideoChannel.name"',
  319. '"VideoChannel"."description"': '"VideoChannel.description"',
  320. '"VideoChannel"."actorId"': '"VideoChannel.actorId"',
  321. '"VideoChannel->Actor"."id"': '"VideoChannel.Actor.id"',
  322. '"VideoChannel->Actor"."preferredUsername"': '"VideoChannel.Actor.preferredUsername"',
  323. '"VideoChannel->Actor"."url"': '"VideoChannel.Actor.url"',
  324. '"VideoChannel->Actor"."serverId"': '"VideoChannel.Actor.serverId"',
  325. '"VideoChannel->Actor"."avatarId"': '"VideoChannel.Actor.avatarId"',
  326. '"VideoChannel->Account"."id"': '"VideoChannel.Account.id"',
  327. '"VideoChannel->Account"."name"': '"VideoChannel.Account.name"',
  328. '"VideoChannel->Account->Actor"."id"': '"VideoChannel.Account.Actor.id"',
  329. '"VideoChannel->Account->Actor"."preferredUsername"': '"VideoChannel.Account.Actor.preferredUsername"',
  330. '"VideoChannel->Account->Actor"."url"': '"VideoChannel.Account.Actor.url"',
  331. '"VideoChannel->Account->Actor"."serverId"': '"VideoChannel.Account.Actor.serverId"',
  332. '"VideoChannel->Account->Actor"."avatarId"': '"VideoChannel.Account.Actor.avatarId"',
  333. '"VideoChannel->Actor->Server"."id"': '"VideoChannel.Actor.Server.id"',
  334. '"VideoChannel->Actor->Server"."host"': '"VideoChannel.Actor.Server.host"',
  335. '"VideoChannel->Actor->Avatar"."id"': '"VideoChannel.Actor.Avatar.id"',
  336. '"VideoChannel->Actor->Avatar"."filename"': '"VideoChannel.Actor.Avatar.filename"',
  337. '"VideoChannel->Actor->Avatar"."fileUrl"': '"VideoChannel.Actor.Avatar.fileUrl"',
  338. '"VideoChannel->Actor->Avatar"."onDisk"': '"VideoChannel.Actor.Avatar.onDisk"',
  339. '"VideoChannel->Actor->Avatar"."createdAt"': '"VideoChannel.Actor.Avatar.createdAt"',
  340. '"VideoChannel->Actor->Avatar"."updatedAt"': '"VideoChannel.Actor.Avatar.updatedAt"',
  341. '"VideoChannel->Account->Actor->Server"."id"': '"VideoChannel.Account.Actor.Server.id"',
  342. '"VideoChannel->Account->Actor->Server"."host"': '"VideoChannel.Account.Actor.Server.host"',
  343. '"VideoChannel->Account->Actor->Avatar"."id"': '"VideoChannel.Account.Actor.Avatar.id"',
  344. '"VideoChannel->Account->Actor->Avatar"."filename"': '"VideoChannel.Account.Actor.Avatar.filename"',
  345. '"VideoChannel->Account->Actor->Avatar"."fileUrl"': '"VideoChannel.Account.Actor.Avatar.fileUrl"',
  346. '"VideoChannel->Account->Actor->Avatar"."onDisk"': '"VideoChannel.Account.Actor.Avatar.onDisk"',
  347. '"VideoChannel->Account->Actor->Avatar"."createdAt"': '"VideoChannel.Account.Actor.Avatar.createdAt"',
  348. '"VideoChannel->Account->Actor->Avatar"."updatedAt"': '"VideoChannel.Account.Actor.Avatar.updatedAt"',
  349. '"Thumbnails"."id"': '"Thumbnails.id"',
  350. '"Thumbnails"."type"': '"Thumbnails.type"',
  351. '"Thumbnails"."filename"': '"Thumbnails.filename"'
  352. }
  353. const joins = [
  354. 'INNER JOIN "video" ON "tmp"."id" = "video"."id"',
  355. 'INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"',
  356. 'INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"',
  357. 'INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"',
  358. 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"',
  359. 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"',
  360. 'LEFT OUTER JOIN "avatar" AS "VideoChannel->Actor->Avatar" ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"',
  361. 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' +
  362. 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"',
  363. 'LEFT OUTER JOIN "avatar" AS "VideoChannel->Account->Actor->Avatar" ' +
  364. 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"',
  365. 'LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"'
  366. ]
  367. if (options.withFiles) {
  368. joins.push('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"')
  369. joins.push('LEFT JOIN "videoStreamingPlaylist" AS "VideoStreamingPlaylists" ON "VideoStreamingPlaylists"."videoId" = "video"."id"')
  370. joins.push(
  371. 'LEFT JOIN "videoFile" AS "VideoStreamingPlaylists->VideoFiles" ' +
  372. 'ON "VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId" = "VideoStreamingPlaylists"."id"'
  373. )
  374. Object.assign(attributes, {
  375. '"VideoFiles"."id"': '"VideoFiles.id"',
  376. '"VideoFiles"."createdAt"': '"VideoFiles.createdAt"',
  377. '"VideoFiles"."updatedAt"': '"VideoFiles.updatedAt"',
  378. '"VideoFiles"."resolution"': '"VideoFiles.resolution"',
  379. '"VideoFiles"."size"': '"VideoFiles.size"',
  380. '"VideoFiles"."extname"': '"VideoFiles.extname"',
  381. '"VideoFiles"."infoHash"': '"VideoFiles.infoHash"',
  382. '"VideoFiles"."fps"': '"VideoFiles.fps"',
  383. '"VideoFiles"."videoId"': '"VideoFiles.videoId"',
  384. '"VideoStreamingPlaylists"."id"': '"VideoStreamingPlaylists.id"',
  385. '"VideoStreamingPlaylists->VideoFiles"."id"': '"VideoStreamingPlaylists.VideoFiles.id"',
  386. '"VideoStreamingPlaylists->VideoFiles"."createdAt"': '"VideoStreamingPlaylists.VideoFiles.createdAt"',
  387. '"VideoStreamingPlaylists->VideoFiles"."updatedAt"': '"VideoStreamingPlaylists.VideoFiles.updatedAt"',
  388. '"VideoStreamingPlaylists->VideoFiles"."resolution"': '"VideoStreamingPlaylists.VideoFiles.resolution"',
  389. '"VideoStreamingPlaylists->VideoFiles"."size"': '"VideoStreamingPlaylists.VideoFiles.size"',
  390. '"VideoStreamingPlaylists->VideoFiles"."extname"': '"VideoStreamingPlaylists.VideoFiles.extname"',
  391. '"VideoStreamingPlaylists->VideoFiles"."infoHash"': '"VideoStreamingPlaylists.VideoFiles.infoHash"',
  392. '"VideoStreamingPlaylists->VideoFiles"."fps"': '"VideoStreamingPlaylists.VideoFiles.fps"',
  393. '"VideoStreamingPlaylists->VideoFiles"."videoId"': '"VideoStreamingPlaylists.VideoFiles.videoId"'
  394. })
  395. }
  396. if (options.user) {
  397. joins.push(
  398. 'LEFT OUTER JOIN "userVideoHistory" ' +
  399. 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId'
  400. )
  401. replacements.userVideoHistoryId = options.user.id
  402. Object.assign(attributes, {
  403. '"userVideoHistory"."id"': '"userVideoHistory.id"',
  404. '"userVideoHistory"."currentTime"': '"userVideoHistory.currentTime"'
  405. })
  406. }
  407. if (options.videoPlaylistId) {
  408. joins.push(
  409. 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
  410. 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
  411. )
  412. replacements.videoPlaylistId = options.videoPlaylistId
  413. Object.assign(attributes, {
  414. '"VideoPlaylistElement"."createdAt"': '"VideoPlaylistElement.createdAt"',
  415. '"VideoPlaylistElement"."updatedAt"': '"VideoPlaylistElement.updatedAt"',
  416. '"VideoPlaylistElement"."url"': '"VideoPlaylistElement.url"',
  417. '"VideoPlaylistElement"."position"': '"VideoPlaylistElement.position"',
  418. '"VideoPlaylistElement"."startTimestamp"': '"VideoPlaylistElement.startTimestamp"',
  419. '"VideoPlaylistElement"."stopTimestamp"': '"VideoPlaylistElement.stopTimestamp"',
  420. '"VideoPlaylistElement"."videoPlaylistId"': '"VideoPlaylistElement.videoPlaylistId"'
  421. })
  422. }
  423. const select = 'SELECT ' + Object.keys(attributes).map(key => {
  424. const value = attributes[key]
  425. if (value) return `${key} AS ${value}`
  426. return key
  427. }).join(', ')
  428. return `${select} FROM (${baseQuery}) AS "tmp" ${joins.join(' ')} ${order}`
  429. }
  430. export {
  431. buildListQuery,
  432. wrapForAPIResults
  433. }