Trending by interval

This commit is contained in:
Chocobozzz 2018-08-31 17:18:13 +02:00
parent 4ccb6c0830
commit 9a629c6efb
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
9 changed files with 116 additions and 34 deletions

View File

@ -4,3 +4,4 @@ export type VideoSortField = 'name' | '-name'
| 'createdAt' | '-createdAt'
| 'views' | '-views'
| 'likes' | '-likes'
| 'trending' | '-trending'

View 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,

View 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

View 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

View 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'

View 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') }
},

View 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 }
}

View 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)

View 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