mirror of https://github.com/Chocobozzz/PeerTube
Trending by interval
parent
4ccb6c0830
commit
9a629c6efb
|
@ -4,3 +4,4 @@ export type VideoSortField = 'name' | '-name'
|
||||||
| 'createdAt' | '-createdAt'
|
| 'createdAt' | '-createdAt'
|
||||||
| 'views' | '-views'
|
| 'views' | '-views'
|
||||||
| 'likes' | '-likes'
|
| 'likes' | '-likes'
|
||||||
|
| 'trending' | '-trending'
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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') }
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue