]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
use focus-visible polyfill to improve keyboard navigation
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index 67b123d77ec09de7488ae73351497d4b7919583e..86316653f026a861f2028c7a383128f7e72ebdb2 100644 (file)
@@ -17,6 +17,7 @@ import {
   HasMany,
   HasOne,
   IFindOptions,
+  IIncludeOptions,
   Is,
   IsInt,
   IsUUID,
@@ -24,8 +25,7 @@ import {
   Model,
   Scopes,
   Table,
-  UpdatedAt,
-  IIncludeOptions
+  UpdatedAt
 } from 'sequelize-typescript'
 import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared'
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
@@ -77,7 +77,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
 import { ActorModel } from '../activitypub/actor'
 import { AvatarModel } from '../avatar/avatar'
 import { ServerModel } from '../server/server'
-import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
+import { buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
 import { TagModel } from './tag'
 import { VideoAbuseModel } from './video-abuse'
 import { VideoChannelModel } from './video-channel'
@@ -89,7 +89,7 @@ import { ScheduleVideoUpdateModel } from './schedule-video-update'
 import { VideoCaptionModel } from './video-caption'
 import { VideoBlacklistModel } from './video-blacklist'
 import { copy, remove, rename, stat, writeFile } from 'fs-extra'
-import { immutableAssign } from '../../tests/utils'
+import { VideoViewModel } from './video-views'
 
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
 const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -113,7 +113,7 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
     unique: true
   },
   {
-    fields: [ 'url'],
+    fields: [ 'url' ],
     unique: true
   }
 ]
@@ -146,10 +146,11 @@ type AvailableForListIDsOptions = {
   withFiles?: boolean
   accountId?: number
   videoChannelId?: number
+  trendingDays?: number
 }
 
 @Scopes({
-  [ScopeNames.FOR_API]: (options: ForAPIOptions) => {
+  [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
     const accountInclude = {
       attributes: [ 'id', 'name' ],
       model: AccountModel.unscoped(),
@@ -202,7 +203,7 @@ type AvailableForListIDsOptions = {
     const query: IFindOptions<VideoModel> = {
       where: {
         id: {
-          [Sequelize.Op.any]: options.ids
+          [ Sequelize.Op.any ]: options.ids
         }
       },
       include: [ videoChannelInclude ]
@@ -217,12 +218,12 @@ type AvailableForListIDsOptions = {
 
     return query
   },
-  [ScopeNames.AVAILABLE_FOR_LIST_IDS]: (options: AvailableForListIDsOptions) => {
+  [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
     const query: IFindOptions<VideoModel> = {
       attributes: [ 'id' ],
       where: {
         id: {
-          [Sequelize.Op.and]: [
+          [ Sequelize.Op.and ]: [
             {
               [ Sequelize.Op.notIn ]: Sequelize.literal(
                 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
@@ -245,7 +246,7 @@ type AvailableForListIDsOptions = {
           }
         ]
       },
-      include: [ ]
+      include: []
     }
 
     if (options.filter || options.accountId || options.videoChannelId) {
@@ -302,27 +303,27 @@ type AvailableForListIDsOptions = {
 
       // Force actorId to be a number to avoid SQL injections
       const actorIdNumber = parseInt(options.actorId.toString(), 10)
-      query.where['id'][Sequelize.Op.and].push({
+      query.where[ 'id' ][ Sequelize.Op.and ].push({
         [ Sequelize.Op.in ]: Sequelize.literal(
           '(' +
-            'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
-            'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
-            'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
-            ' UNION ALL ' +
-            'SELECT "video"."id" AS "id" FROM "video" ' +
-            'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
-            'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
-            'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
-            'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
-            'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
-            localVideosReq +
+          'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
+          'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
+          'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+          ' UNION ALL ' +
+          'SELECT "video"."id" AS "id" FROM "video" ' +
+          'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
+          'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
+          'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
+          'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
+          'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+          localVideosReq +
           ')'
         )
       })
     }
 
     if (options.withFiles === true) {
-      query.where['id'][Sequelize.Op.and].push({
+      query.where[ 'id' ][ Sequelize.Op.and ].push({
         [ Sequelize.Op.in ]: Sequelize.literal(
           '(SELECT "videoId" FROM "videoFile")'
         )
@@ -337,8 +338,8 @@ type AvailableForListIDsOptions = {
       }
 
       if (options.tagsOneOf) {
-        query.where['id'][Sequelize.Op.and].push({
-          [Sequelize.Op.in]: Sequelize.literal(
+        query.where[ 'id' ][ Sequelize.Op.and ].push({
+          [ Sequelize.Op.in ]: Sequelize.literal(
             '(' +
             'SELECT "videoId" FROM "videoTag" ' +
             'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
@@ -349,8 +350,8 @@ type AvailableForListIDsOptions = {
       }
 
       if (options.tagsAllOf) {
-        query.where['id'][Sequelize.Op.and].push({
-          [Sequelize.Op.in]: Sequelize.literal(
+        query.where[ 'id' ][ Sequelize.Op.and ].push({
+          [ Sequelize.Op.in ]: Sequelize.literal(
             '(' +
             'SELECT "videoId" FROM "videoTag" ' +
             'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
@@ -363,30 +364,45 @@ type AvailableForListIDsOptions = {
     }
 
     if (options.nsfw === true || options.nsfw === false) {
-      query.where['nsfw'] = options.nsfw
+      query.where[ 'nsfw' ] = options.nsfw
     }
 
     if (options.categoryOneOf) {
-      query.where['category'] = {
-        [Sequelize.Op.or]: options.categoryOneOf
+      query.where[ 'category' ] = {
+        [ Sequelize.Op.or ]: options.categoryOneOf
       }
     }
 
     if (options.licenceOneOf) {
-      query.where['licence'] = {
-        [Sequelize.Op.or]: options.licenceOneOf
+      query.where[ 'licence' ] = {
+        [ Sequelize.Op.or ]: options.licenceOneOf
       }
     }
 
     if (options.languageOneOf) {
-      query.where['language'] = {
-        [Sequelize.Op.or]: options.languageOneOf
+      query.where[ 'language' ] = {
+        [ Sequelize.Op.or ]: options.languageOneOf
       }
     }
 
+    if (options.trendingDays) {
+      query.include.push({
+        attributes: [],
+        model: VideoViewModel,
+        required: false,
+        where: {
+          startDate: {
+            [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays)
+          }
+        }
+      })
+
+      query.subQuery = false
+    }
+
     return query
   },
-  [ScopeNames.WITH_ACCOUNT_DETAILS]: {
+  [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
     include: [
       {
         model: () => VideoChannelModel.unscoped(),
@@ -438,10 +454,10 @@ type AvailableForListIDsOptions = {
       }
     ]
   },
-  [ScopeNames.WITH_TAGS]: {
+  [ ScopeNames.WITH_TAGS ]: {
     include: [ () => TagModel ]
   },
-  [ScopeNames.WITH_BLACKLISTED]: {
+  [ ScopeNames.WITH_BLACKLISTED ]: {
     include: [
       {
         attributes: [ 'id', 'reason' ],
@@ -450,7 +466,7 @@ type AvailableForListIDsOptions = {
       }
     ]
   },
-  [ScopeNames.WITH_FILES]: {
+  [ ScopeNames.WITH_FILES ]: {
     include: [
       {
         model: () => VideoFileModel.unscoped(),
@@ -458,7 +474,7 @@ type AvailableForListIDsOptions = {
       }
     ]
   },
-  [ScopeNames.WITH_SCHEDULED_UPDATE]: {
+  [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
     include: [
       {
         model: () => ScheduleVideoUpdateModel.unscoped(),
@@ -649,6 +665,16 @@ export class VideoModel extends Model<VideoModel> {
   })
   VideoComments: VideoCommentModel[]
 
+  @HasMany(() => VideoViewModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade',
+    hooks: true
+  })
+  VideoViews: VideoViewModel[]
+
   @HasOne(() => ScheduleVideoUpdateModel, {
     foreignKey: {
       name: 'videoId',
@@ -674,7 +700,7 @@ export class VideoModel extends Model<VideoModel> {
     },
     onDelete: 'cascade',
     hooks: true,
-    ['separate' as any]: true
+    [ 'separate' as any ]: true
   })
   VideoCaptions: VideoCaptionModel[]
 
@@ -723,9 +749,9 @@ export class VideoModel extends Model<VideoModel> {
 
     // Do not wait video deletion because we could be in a transaction
     Promise.all(tasks)
-      .catch(err => {
-        logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
-      })
+           .catch(err => {
+             logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
+           })
 
     return undefined
   }
@@ -754,12 +780,12 @@ export class VideoModel extends Model<VideoModel> {
       distinct: true,
       offset: start,
       limit: count,
-      order: getSort('createdAt', [ 'Tags', 'name', 'ASC' ]),
+      order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ]),
       where: {
         id: {
-          [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
+          [ Sequelize.Op.in ]: Sequelize.literal('(' + rawQuery + ')')
         },
-        [Sequelize.Op.or]: [
+        [ Sequelize.Op.or ]: [
           { privacy: VideoPrivacy.PUBLIC },
           { privacy: VideoPrivacy.UNLISTED }
         ]
@@ -776,10 +802,10 @@ export class VideoModel extends Model<VideoModel> {
           required: false,
           // We only want videos shared by this actor
           where: {
-            [Sequelize.Op.and]: [
+            [ Sequelize.Op.and ]: [
               {
                 id: {
-                  [Sequelize.Op.not]: null
+                  [ Sequelize.Op.not ]: null
                 }
               },
               {
@@ -830,8 +856,8 @@ export class VideoModel extends Model<VideoModel> {
       // totals: totalVideos + totalVideoShares
       let totalVideos = 0
       let totalVideoShares = 0
-      if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
-      if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
+      if (totals[ 0 ]) totalVideos = parseInt(totals[ 0 ].total, 10)
+      if (totals[ 1 ]) totalVideoShares = parseInt(totals[ 1 ].total, 10)
 
       const total = totalVideos + totalVideoShares
       return {
@@ -845,7 +871,7 @@ export class VideoModel extends Model<VideoModel> {
     const query: IFindOptions<VideoModel> = {
       offset: start,
       limit: count,
-      order: getSort(sort),
+      order: getVideoSort(sort),
       include: [
         {
           model: VideoChannelModel,
@@ -902,11 +928,19 @@ export class VideoModel extends Model<VideoModel> {
     accountId?: number,
     videoChannelId?: number,
     actorId?: number
+    trendingDays?: number
   }) {
-    const query = {
+    const query: IFindOptions<VideoModel> = {
       offset: options.start,
       limit: options.count,
-      order: getSort(options.sort)
+      order: getVideoSort(options.sort)
+    }
+
+    let trendingDays: number
+    if (options.sort.endsWith('trending')) {
+      trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
+
+      query.group = 'VideoModel.id'
     }
 
     // actorId === null has a meaning, so just check undefined
@@ -924,7 +958,8 @@ export class VideoModel extends Model<VideoModel> {
       withFiles: options.withFiles,
       accountId: options.accountId,
       videoChannelId: options.videoChannelId,
-      includeLocalVideos: options.includeLocalVideos
+      includeLocalVideos: options.includeLocalVideos,
+      trendingDays
     }
 
     return VideoModel.getAvailableForApi(query, queryOptions)
@@ -947,22 +982,22 @@ export class VideoModel extends Model<VideoModel> {
     durationMin?: number // seconds
     durationMax?: number // seconds
   }) {
-    const whereAnd = [ ]
+    const whereAnd = []
 
     if (options.startDate || options.endDate) {
-      const publishedAtRange = { }
+      const publishedAtRange = {}
 
-      if (options.startDate) publishedAtRange[Sequelize.Op.gte] = options.startDate
-      if (options.endDate) publishedAtRange[Sequelize.Op.lte] = options.endDate
+      if (options.startDate) publishedAtRange[ Sequelize.Op.gte ] = options.startDate
+      if (options.endDate) publishedAtRange[ Sequelize.Op.lte ] = options.endDate
 
       whereAnd.push({ publishedAt: publishedAtRange })
     }
 
     if (options.durationMin || options.durationMax) {
-      const durationRange = { }
+      const durationRange = {}
 
-      if (options.durationMin) durationRange[Sequelize.Op.gte] = options.durationMin
-      if (options.durationMax) durationRange[Sequelize.Op.lte] = options.durationMax
+      if (options.durationMin) durationRange[ Sequelize.Op.gte ] = options.durationMin
+      if (options.durationMax) durationRange[ Sequelize.Op.lte ] = options.durationMax
 
       whereAnd.push({ duration: durationRange })
     }
@@ -976,14 +1011,14 @@ export class VideoModel extends Model<VideoModel> {
           id: {
             [ Sequelize.Op.in ]: Sequelize.literal(
               '(' +
-                'SELECT "video"."id" FROM "video" ' +
-                'WHERE ' +
-                'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
-                'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
-                'UNION ALL ' +
-                'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
-                'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
-                'WHERE "tag"."name" = ' + escapedSearch +
+              'SELECT "video"."id" FROM "video" ' +
+              'WHERE ' +
+              'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
+              'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
+              'UNION ALL ' +
+              'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
+              'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
+              'WHERE "tag"."name" = ' + escapedSearch +
               ')'
             )
           }
@@ -1006,7 +1041,7 @@ export class VideoModel extends Model<VideoModel> {
       },
       offset: options.start,
       limit: options.count,
-      order: getSort(options.sort),
+      order: getVideoSort(options.sort),
       where: {
         [ Sequelize.Op.and ]: whereAnd
       }
@@ -1132,11 +1167,11 @@ export class VideoModel extends Model<VideoModel> {
       limit: count,
       group: field,
       having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), {
-        [Sequelize.Op.gte]: threshold
+        [ Sequelize.Op.gte ]: threshold
       }) as any, // FIXME: typings
       where: {
-        [field]: {
-          [Sequelize.Op.not]: null
+        [ field ]: {
+          [ Sequelize.Op.not ]: null
         },
         privacy: VideoPrivacy.PUBLIC,
         state: VideoState.PUBLISHED
@@ -1145,7 +1180,7 @@ export class VideoModel extends Model<VideoModel> {
     }
 
     return VideoModel.findAll(query)
-      .then(rows => rows.map(r => r[field]))
+                     .then(rows => rows.map(r => r[ field ]))
   }
 
   private static buildActorWhereWithFilter (filter?: VideoFilter) {
@@ -1165,7 +1200,19 @@ export class VideoModel extends Model<VideoModel> {
       ]
     }
 
-    const { count, rows: rowsId } = await VideoModel.scope(idsScope).findAndCountAll(query)
+    // Remove trending sort on count, because it uses a group by
+    const countOptions = Object.assign({}, options, { trendingDays: undefined })
+    const countQuery = Object.assign({}, query, { attributes: undefined, group: undefined })
+    const countScope = {
+      method: [
+        ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
+      ]
+    }
+
+    const [ count, rowsId ] = await Promise.all([
+      VideoModel.scope(countScope).count(countQuery),
+      VideoModel.scope(idsScope).findAll(query)
+    ])
     const ids = rowsId.map(r => r.id)
 
     if (ids.length === 0) return { data: [], total: count }
@@ -1177,8 +1224,12 @@ export class VideoModel extends Model<VideoModel> {
     const secondQuery = {
       offset: 0,
       limit: query.limit,
-      order: query.order,
-      attributes: query.attributes
+      attributes: query.attributes,
+      order: [ // Keep original order
+        Sequelize.literal(
+          ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ')
+        )
+      ]
     }
     const rows = await VideoModel.scope(apiScope).findAll(secondQuery)
 
@@ -1189,23 +1240,23 @@ export class VideoModel extends Model<VideoModel> {
   }
 
   private static getCategoryLabel (id: number) {
-    return VIDEO_CATEGORIES[id] || 'Misc'
+    return VIDEO_CATEGORIES[ id ] || 'Misc'
   }
 
   private static getLicenceLabel (id: number) {
-    return VIDEO_LICENCES[id] || 'Unknown'
+    return VIDEO_LICENCES[ id ] || 'Unknown'
   }
 
   private static getLanguageLabel (id: string) {
-    return VIDEO_LANGUAGES[id] || 'Unknown'
+    return VIDEO_LANGUAGES[ id ] || 'Unknown'
   }
 
   private static getPrivacyLabel (id: number) {
-    return VIDEO_PRIVACIES[id] || 'Unknown'
+    return VIDEO_PRIVACIES[ id ] || 'Unknown'
   }
 
   private static getStateLabel (id: number) {
-    return VIDEO_STATES[id] || 'Unknown'
+    return VIDEO_STATES[ id ] || 'Unknown'
   }
 
   getOriginalFile () {
@@ -1427,28 +1478,28 @@ export class VideoModel extends Model<VideoModel> {
     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
 
     return this.VideoFiles
-        .map(videoFile => {
-          let resolutionLabel = videoFile.resolution + 'p'
-
-          return {
-            resolution: {
-              id: videoFile.resolution,
-              label: resolutionLabel
-            },
-            magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
-            size: videoFile.size,
-            fps: videoFile.fps,
-            torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
-            torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
-            fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
-            fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
-          } as VideoFile
-        })
-        .sort((a, b) => {
-          if (a.resolution.id < b.resolution.id) return 1
-          if (a.resolution.id === b.resolution.id) return 0
-          return -1
-        })
+               .map(videoFile => {
+                 let resolutionLabel = videoFile.resolution + 'p'
+
+                 return {
+                   resolution: {
+                     id: videoFile.resolution,
+                     label: resolutionLabel
+                   },
+                   magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
+                   size: videoFile.size,
+                   fps: videoFile.fps,
+                   torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
+                   torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
+                   fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
+                   fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
+                 } as VideoFile
+               })
+               .sort((a, b) => {
+                 if (a.resolution.id < b.resolution.id) return 1
+                 if (a.resolution.id === b.resolution.id) return 0
+                 return -1
+               })
   }
 
   toActivityPubObject (): VideoTorrentObject {
@@ -1488,7 +1539,7 @@ export class VideoModel extends Model<VideoModel> {
     for (const file of this.VideoFiles) {
       url.push({
         type: 'Link',
-        mimeType: VIDEO_EXT_MIMETYPE[file.extname],
+        mimeType: VIDEO_EXT_MIMETYPE[ file.extname ],
         href: this.getVideoFileUrl(file, baseUrlHttp),
         height: file.resolution,
         size: file.size,