Trending by interval
This commit is contained in:
parent
4ccb6c0830
commit
9a629c6efb
|
@ -4,3 +4,4 @@ export type VideoSortField = 'name' | '-name'
|
|||
| 'createdAt' | '-createdAt'
|
||||
| 'views' | '-views'
|
||||
| 'likes' | '-likes'
|
||||
| 'trending' | '-trending'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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') }
|
||||
},
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue