Trending by interval

pull/1000/merge
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' | 'createdAt' | '-createdAt'
| 'views' | '-views' | 'views' | '-views'
| 'likes' | '-likes' | '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 { export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string titlePage: string
currentRoute = '/videos/trending' currentRoute = '/videos/trending'
defaultSort: VideoSortField = '-views' defaultSort: VideoSortField = '-trending'
constructor ( constructor (
protected router: Router, protected router: Router,

View File

@ -62,6 +62,10 @@ search:
users: true users: true
anonymous: false anonymous: false
trending:
videos:
interval_days: 7 # Compute trending videos for the last x days
cache: cache:
previews: previews:
size: 500 # Max number of previews you want to cache size: 500 # Max number of previews you want to cache

View File

@ -63,6 +63,10 @@ search:
users: true users: true
anonymous: false 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 # 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.enabled', 'signup.limit', 'signup.requires_email_verification',
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
'transcoding.enabled', 'transcoding.threads', '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.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
'instance.default_nsfw_policy', 'instance.robots', 'instance.default_nsfw_policy', 'instance.robots',
'services.twitter.username', 'services.twitter.whitelisted' 'services.twitter.username', 'services.twitter.whitelisted'

View File

@ -37,14 +37,15 @@ const SORTABLE_COLUMNS = {
JOBS: [ 'createdAt' ], JOBS: [ 'createdAt' ],
VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ], VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ],
VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ], VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ],
VIDEO_IMPORTS: [ 'createdAt' ], VIDEO_IMPORTS: [ 'createdAt' ],
VIDEO_COMMENT_THREADS: [ 'createdAt' ], VIDEO_COMMENT_THREADS: [ 'createdAt' ],
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
FOLLOWERS: [ 'createdAt' ], FOLLOWERS: [ 'createdAt' ],
FOLLOWING: [ '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' ] VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ]
} }
@ -201,6 +202,11 @@ const CONFIG = {
ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous') ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous')
} }
}, },
TRENDING: {
VIDEOS: {
INTERVAL_DAYS: config.get<number>('trending.videos.interval_days')
}
},
ADMIN: { ADMIN: {
get EMAIL () { return config.get<string>('admin.email') } 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' import { Sequelize } from 'sequelize-typescript'
type SortType = { sortModel: any, sortValue: string } type SortType = { sortModel: any, sortValue: string }
// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) { function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
let field: any const { direction, field } = buildDirectionAndField(value)
let direction: 'ASC' | 'DESC'
if (value.substring(0, 1) === '-') { return [ [ field, direction ], lastSort ]
direction = 'DESC' }
field = value.substring(1)
} else { function getVideoSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
direction = 'ASC' let { direction, field } = buildDirectionAndField(value)
field = value
}
// Alias // Alias
if (field.toLowerCase() === 'match') field = Sequelize.col('similarity') 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 ] return [ [ field, direction ], lastSort ]
} }
@ -58,6 +66,7 @@ function createSimilarityAttribute (col: string, value: string) {
export { export {
SortType, SortType,
getSort, getSort,
getVideoSort,
getSortOnModel, getSortOnModel,
createSimilarityAttribute, createSimilarityAttribute,
throwIfNotValid, throwIfNotValid,
@ -73,3 +82,18 @@ function searchTrigramNormalizeValue (value: string) {
function searchTrigramNormalizeCol (col: string) { function searchTrigramNormalizeCol (col: string) {
return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col))) 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, HasMany,
HasOne, HasOne,
IFindOptions, IFindOptions,
IIncludeOptions,
Is, Is,
IsInt, IsInt,
IsUUID, IsUUID,
@ -24,8 +25,7 @@ import {
Model, Model,
Scopes, Scopes,
Table, Table,
UpdatedAt, UpdatedAt
IIncludeOptions
} from 'sequelize-typescript' } from 'sequelize-typescript'
import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared' import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared'
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
@ -77,7 +77,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
import { ActorModel } from '../activitypub/actor' import { ActorModel } from '../activitypub/actor'
import { AvatarModel } from '../avatar/avatar' import { AvatarModel } from '../avatar/avatar'
import { ServerModel } from '../server/server' import { ServerModel } from '../server/server'
import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' import { buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
import { TagModel } from './tag' import { TagModel } from './tag'
import { VideoAbuseModel } from './video-abuse' import { VideoAbuseModel } from './video-abuse'
import { VideoChannelModel } from './video-channel' import { VideoChannelModel } from './video-channel'
@ -89,7 +89,7 @@ import { ScheduleVideoUpdateModel } from './schedule-video-update'
import { VideoCaptionModel } from './video-caption' import { VideoCaptionModel } from './video-caption'
import { VideoBlacklistModel } from './video-blacklist' import { VideoBlacklistModel } from './video-blacklist'
import { copy, remove, rename, stat, writeFile } from 'fs-extra' 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 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
const indexes: Sequelize.DefineIndexesOptions[] = [ const indexes: Sequelize.DefineIndexesOptions[] = [
@ -146,6 +146,7 @@ type AvailableForListIDsOptions = {
withFiles?: boolean withFiles?: boolean
accountId?: number accountId?: number
videoChannelId?: number videoChannelId?: number
trendingDays?: number
} }
@Scopes({ @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 return query
}, },
[ScopeNames.WITH_ACCOUNT_DETAILS]: { [ScopeNames.WITH_ACCOUNT_DETAILS]: {
@ -649,6 +665,16 @@ export class VideoModel extends Model<VideoModel> {
}) })
VideoComments: VideoCommentModel[] VideoComments: VideoCommentModel[]
@HasMany(() => VideoViewModel, {
foreignKey: {
name: 'videoId',
allowNull: false
},
onDelete: 'cascade',
hooks: true
})
VideoViews: VideoViewModel[]
@HasOne(() => ScheduleVideoUpdateModel, { @HasOne(() => ScheduleVideoUpdateModel, {
foreignKey: { foreignKey: {
name: 'videoId', name: 'videoId',
@ -754,7 +780,7 @@ export class VideoModel extends Model<VideoModel> {
distinct: true, distinct: true,
offset: start, offset: start,
limit: count, limit: count,
order: getSort('createdAt', [ 'Tags', 'name', 'ASC' ]), order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ]),
where: { where: {
id: { id: {
[Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')') [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
@ -845,7 +871,7 @@ export class VideoModel extends Model<VideoModel> {
const query: IFindOptions<VideoModel> = { const query: IFindOptions<VideoModel> = {
offset: start, offset: start,
limit: count, limit: count,
order: getSort(sort), order: getVideoSort(sort),
include: [ include: [
{ {
model: VideoChannelModel, model: VideoChannelModel,
@ -902,11 +928,19 @@ export class VideoModel extends Model<VideoModel> {
accountId?: number, accountId?: number,
videoChannelId?: number, videoChannelId?: number,
actorId?: number actorId?: number
trendingDays?: number
}) { }) {
const query = { const query: IFindOptions<VideoModel> = {
offset: options.start, offset: options.start,
limit: options.count, 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 // actorId === null has a meaning, so just check undefined
@ -924,7 +958,8 @@ export class VideoModel extends Model<VideoModel> {
withFiles: options.withFiles, withFiles: options.withFiles,
accountId: options.accountId, accountId: options.accountId,
videoChannelId: options.videoChannelId, videoChannelId: options.videoChannelId,
includeLocalVideos: options.includeLocalVideos includeLocalVideos: options.includeLocalVideos,
trendingDays
} }
return VideoModel.getAvailableForApi(query, queryOptions) return VideoModel.getAvailableForApi(query, queryOptions)
@ -1006,7 +1041,7 @@ export class VideoModel extends Model<VideoModel> {
}, },
offset: options.start, offset: options.start,
limit: options.count, limit: options.count,
order: getSort(options.sort), order: getVideoSort(options.sort),
where: { where: {
[ Sequelize.Op.and ]: whereAnd [ Sequelize.Op.and ]: whereAnd
} }
@ -1177,8 +1212,12 @@ export class VideoModel extends Model<VideoModel> {
const secondQuery = { const secondQuery = {
offset: 0, offset: 0,
limit: query.limit, 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) 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) 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 () { it('Should upload 5 videos in a specific category, tag and channel but not include them in overview', async function () {
for (let i = 0; i < 3; i++) { this.timeout(15000)
for (let i = 0; i < 5; i++) {
await uploadVideo(server.url, server.accessToken, { await uploadVideo(server.url, server.accessToken, {
name: 'video ' + i, name: 'video ' + i,
category: 3, 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 () { it('Should upload another video and include all videos in the overview', async function () {
await uploadVideo(server.url, server.accessToken, { await uploadVideo(server.url, server.accessToken, {
name: 'video 3', name: 'video 5',
category: 3, category: 3,
tags: [ 'coucou1', 'coucou2' ] tags: [ 'coucou1', 'coucou2' ]
}) })
@ -70,11 +72,13 @@ describe('Test a videos overview', function () {
for (const attr of [ 'tags', 'categories', 'channels' ]) { for (const attr of [ 'tags', 'categories', 'channels' ]) {
const obj = overview[attr][0] const obj = overview[attr][0]
expect(obj.videos).to.have.lengthOf(4) expect(obj.videos).to.have.lengthOf(6)
expect(obj.videos[0].name).to.equal('video 3') expect(obj.videos[0].name).to.equal('video 5')
expect(obj.videos[1].name).to.equal('video 2') expect(obj.videos[1].name).to.equal('video 4')
expect(obj.videos[2].name).to.equal('video 1') expect(obj.videos[2].name).to.equal('video 3')
expect(obj.videos[3].name).to.equal('video 0') 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 expect(overview.tags.find(t => t.tag === 'coucou1')).to.not.be.undefined