Trending by interval
authorChocobozzz <me@florianbigard.com>
Fri, 31 Aug 2018 15:18:13 +0000 (17:18 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 31 Aug 2018 15:22:01 +0000 (17:22 +0200)
client/src/app/shared/video/sort-field.type.ts
client/src/app/videos/video-list/video-trending.component.ts
config/default.yaml
config/production.yaml.example
server/initializers/checker.ts
server/initializers/constants.ts
server/models/utils.ts
server/models/video/video.ts
server/tests/api/videos/videos-overview.ts

index 2192745b9f4dbc120089dc1552d5ac71c2973f60..d1088d24495e35f3cbd8bac0f88ce8dc34ef1867 100644 (file)
@@ -4,3 +4,4 @@ export type VideoSortField = 'name' | '-name'
                       | 'createdAt' | '-createdAt'
                       | 'views' | '-views'
                       | 'likes' | '-likes'
+                      | 'trending' | '-trending'
index 68bb70265c83e75cc2327661c296de06cc651189..8f3d3842b494a31c74e6de679ee8c3159b5fb918 100644 (file)
@@ -18,7 +18,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
 export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
   titlePage: string
   currentRoute = '/videos/trending'
-  defaultSort: VideoSortField = '-views'
+  defaultSort: VideoSortField = '-trending'
 
   constructor (
     protected router: Router,
index ef63fbd281f1064f261988eef53df6b2dbc4686d..254fa0c99f979299e16af31b5d5783a625bb7fe4 100644 (file)
@@ -62,6 +62,10 @@ search:
     users: true
     anonymous: false
 
+trending:
+  videos:
+    interval_days: 7 # Compute trending videos for the last x days
+
 cache:
   previews:
     size: 500 # Max number of previews you want to cache
index f7b15369867bf0fedbb4d1a1aa0fcc9d410e6e39..e33427faeb1eceb9dd489cb0d2afd67e39618f1a 100644 (file)
@@ -63,6 +63,10 @@ search:
     users: true
     anonymous: false
 
+trending:
+  videos:
+    interval_days: 7 # Compute trending videos for the last x days
+
 ###############################################################################
 #
 # From this point, all the following keys can be overridden by the web interface
index ee02ecf4867a04c43047acd2d26eba4bc1afb1ca..b126bf67edfdfde77724880a51f967d3ed7750c9 100644 (file)
@@ -52,7 +52,7 @@ function checkMissedConfig () {
     'signup.enabled', 'signup.limit', 'signup.requires_email_verification',
     'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
     'transcoding.enabled', 'transcoding.threads',
-    'import.videos.http.enabled',
+    'import.videos.http.enabled', 'import.videos.torrent.enabled',
     'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
     'instance.default_nsfw_policy', 'instance.robots',
     'services.twitter.username', 'services.twitter.whitelisted'
index 6d0503f48df2a4d508abfa6e417d2f8327a2e35c..efe27a241c764f69f3460e4d3baffc2e44dde9b0 100644 (file)
@@ -37,14 +37,15 @@ const SORTABLE_COLUMNS = {
   JOBS: [ 'createdAt' ],
   VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ],
   VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
-  VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ],
   VIDEO_IMPORTS: [ 'createdAt' ],
   VIDEO_COMMENT_THREADS: [ 'createdAt' ],
   BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
   FOLLOWERS: [ 'createdAt' ],
   FOLLOWING: [ 'createdAt' ],
 
-  VIDEOS_SEARCH: [ 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ],
+  VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'trending' ],
+
+  VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'match' ],
   VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ]
 }
 
@@ -201,6 +202,11 @@ const CONFIG = {
       ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous')
     }
   },
+  TRENDING: {
+    VIDEOS: {
+      INTERVAL_DAYS: config.get<number>('trending.videos.interval_days')
+    }
+  },
   ADMIN: {
     get EMAIL () { return config.get<string>('admin.email') }
   },
index eb6653f3ddf24d906c4333a7856fd26d8923618c..edb8e1161916abcf8a7da00428a5edf064296d77 100644 (file)
@@ -1,23 +1,31 @@
-// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
 import { Sequelize } from 'sequelize-typescript'
 
 type SortType = { sortModel: any, sortValue: string }
 
+// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
 function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
-  let field: any
-  let direction: 'ASC' | 'DESC'
+  const { direction, field } = buildDirectionAndField(value)
 
-  if (value.substring(0, 1) === '-') {
-    direction = 'DESC'
-    field = value.substring(1)
-  } else {
-    direction = 'ASC'
-    field = value
-  }
+  return [ [ field, direction ], lastSort ]
+}
+
+function getVideoSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
+  let { direction, field } = buildDirectionAndField(value)
 
   // Alias
   if (field.toLowerCase() === 'match') field = Sequelize.col('similarity')
 
+  // Sort by aggregation
+  if (field.toLowerCase() === 'trending') {
+    return [
+      [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
+
+      [ Sequelize.col('VideoModel.views'), direction ],
+
+      lastSort
+    ]
+  }
+
   return [ [ field, direction ], lastSort ]
 }
 
@@ -58,6 +66,7 @@ function createSimilarityAttribute (col: string, value: string) {
 export {
   SortType,
   getSort,
+  getVideoSort,
   getSortOnModel,
   createSimilarityAttribute,
   throwIfNotValid,
@@ -73,3 +82,18 @@ function searchTrigramNormalizeValue (value: string) {
 function searchTrigramNormalizeCol (col: string) {
   return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
 }
+
+function buildDirectionAndField (value: string) {
+  let field: any
+  let direction: 'ASC' | 'DESC'
+
+  if (value.substring(0, 1) === '-') {
+    direction = 'DESC'
+    field = value.substring(1)
+  } else {
+    direction = 'ASC'
+    field = value
+  }
+
+  return { direction, field }
+}
index 67b123d77ec09de7488ae73351497d4b7919583e..6fb5ececae5b29886202f312f0f90d44f88a3b3d 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[] = [
@@ -146,6 +146,7 @@ type AvailableForListIDsOptions = {
   withFiles?: boolean
   accountId?: number
   videoChannelId?: number
+  trendingDays?: number
 }
 
 @Scopes({
@@ -384,6 +385,21 @@ type AvailableForListIDsOptions = {
       }
     }
 
+    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]: {
@@ -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',
@@ -754,7 +780,7 @@ 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 + ')')
@@ -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)
@@ -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
       }
@@ -1177,8 +1212,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)
 
index 1514d1bda07992c9a1fc3fe20619d2777d434609..7d1f29c92ee8a6933840b0223b0f9bd1e39a7fc2 100644 (file)
@@ -30,8 +30,10 @@ describe('Test a videos overview', function () {
     expect(overview.channels).to.have.lengthOf(0)
   })
 
-  it('Should upload 3 videos in a specific category, tag and channel but not include them in overview', async function () {
-    for (let i = 0; i < 3; i++) {
+  it('Should upload 5 videos in a specific category, tag and channel but not include them in overview', async function () {
+    this.timeout(15000)
+
+    for (let i = 0; i < 5; i++) {
       await uploadVideo(server.url, server.accessToken, {
         name: 'video ' + i,
         category: 3,
@@ -49,7 +51,7 @@ describe('Test a videos overview', function () {
 
   it('Should upload another video and include all videos in the overview', async function () {
     await uploadVideo(server.url, server.accessToken, {
-      name: 'video 3',
+      name: 'video 5',
       category: 3,
       tags: [ 'coucou1', 'coucou2' ]
     })
@@ -70,11 +72,13 @@ describe('Test a videos overview', function () {
     for (const attr of [ 'tags', 'categories', 'channels' ]) {
       const obj = overview[attr][0]
 
-      expect(obj.videos).to.have.lengthOf(4)
-      expect(obj.videos[0].name).to.equal('video 3')
-      expect(obj.videos[1].name).to.equal('video 2')
-      expect(obj.videos[2].name).to.equal('video 1')
-      expect(obj.videos[3].name).to.equal('video 0')
+      expect(obj.videos).to.have.lengthOf(6)
+      expect(obj.videos[0].name).to.equal('video 5')
+      expect(obj.videos[1].name).to.equal('video 4')
+      expect(obj.videos[2].name).to.equal('video 3')
+      expect(obj.videos[3].name).to.equal('video 2')
+      expect(obj.videos[4].name).to.equal('video 1')
+      expect(obj.videos[5].name).to.equal('video 0')
     }
 
     expect(overview.tags.find(t => t.tag === 'coucou1')).to.not.be.undefined