| 'createdAt' | '-createdAt'
| 'views' | '-views'
| 'likes' | '-likes'
+ | 'trending' | '-trending'
export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string
currentRoute = '/videos/trending'
- defaultSort: VideoSortField = '-views'
+ defaultSort: VideoSortField = '-trending'
constructor (
protected router: Router,
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
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
'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'
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' ]
}
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') }
},
-// 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 ]
}
export {
SortType,
getSort,
+ getVideoSort,
getSortOnModel,
createSimilarityAttribute,
throwIfNotValid,
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 }
+}
HasMany,
HasOne,
IFindOptions,
+ IIncludeOptions,
Is,
IsInt,
IsUUID,
Model,
Scopes,
Table,
- UpdatedAt,
- IIncludeOptions
+ UpdatedAt
} from 'sequelize-typescript'
import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared'
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
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'
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[] = [
withFiles?: boolean
accountId?: number
videoChannelId?: number
+ trendingDays?: number
}
@Scopes({
}
}
+ 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]: {
})
VideoComments: VideoCommentModel[]
+ @HasMany(() => VideoViewModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade',
+ hooks: true
+ })
+ VideoViews: VideoViewModel[]
+
@HasOne(() => ScheduleVideoUpdateModel, {
foreignKey: {
name: 'videoId',
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 + ')')
const query: IFindOptions<VideoModel> = {
offset: start,
limit: count,
- order: getSort(sort),
+ order: getVideoSort(sort),
include: [
{
model: VideoChannelModel,
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
withFiles: options.withFiles,
accountId: options.accountId,
videoChannelId: options.videoChannelId,
- includeLocalVideos: options.includeLocalVideos
+ includeLocalVideos: options.includeLocalVideos,
+ trendingDays
}
return VideoModel.getAvailableForApi(query, queryOptions)
},
offset: options.start,
limit: options.count,
- order: getSort(options.sort),
+ order: getVideoSort(options.sort),
where: {
[ Sequelize.Op.and ]: whereAnd
}
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)
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,
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' ]
})
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