add best trending strategy based on Reddit's best
inspired from https://www.reddit.com/r/changelog/comments/7spgg0/best_is_the_new_hotness/ this implementation only adds freshness, and doesn't personalize based on subscribed communities yet.
This commit is contained in:
parent
f6267b6101
commit
3d4e112d16
|
@ -271,6 +271,7 @@
|
||||||
<option i18n value="/videos/overview">Discover videos</option>
|
<option i18n value="/videos/overview">Discover videos</option>
|
||||||
<optgroup i18n-label label="Trending pages">
|
<optgroup i18n-label label="Trending pages">
|
||||||
<option i18n value="/videos/trending">Default trending page</option>
|
<option i18n value="/videos/trending">Default trending page</option>
|
||||||
|
<option i18n value="/videos/trending?alg=best" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('best')">Best videos</option>
|
||||||
<option i18n value="/videos/trending?alg=hot" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('hot')">Hot videos</option>
|
<option i18n value="/videos/trending?alg=hot" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('hot')">Hot videos</option>
|
||||||
<option i18n value="/videos/trending?alg=most-viewed" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('most-viewed')">Most viewed videos</option>
|
<option i18n value="/videos/trending?alg=most-viewed" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('most-viewed')">Most viewed videos</option>
|
||||||
<option i18n value="/videos/trending?alg=most-liked" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('most-liked')">Most liked videos</option>
|
<option i18n value="/videos/trending?alg=most-liked" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('most-liked')">Most liked videos</option>
|
||||||
|
@ -288,6 +289,7 @@
|
||||||
<label i18n for="trendingVideosAlgorithmsDefault">Default trending page</label>
|
<label i18n for="trendingVideosAlgorithmsDefault">Default trending page</label>
|
||||||
<div class="peertube-select-container">
|
<div class="peertube-select-container">
|
||||||
<select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
|
<select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
|
||||||
|
<option i18n value="best">Best videos</option>
|
||||||
<option i18n value="hot">Hot videos</option>
|
<option i18n value="hot">Hot videos</option>
|
||||||
<option i18n value="most-viewed">Most viewed videos</option>
|
<option i18n value="most-viewed">Most viewed videos</option>
|
||||||
<option i18n value="most-liked">Most liked videos</option>
|
<option i18n value="most-liked">Most liked videos</option>
|
||||||
|
|
|
@ -35,6 +35,13 @@ export class VideoTrendingHeaderComponent extends VideoListHeaderComponent imple
|
||||||
super(data)
|
super(data)
|
||||||
|
|
||||||
this.buttons = [
|
this.buttons = [
|
||||||
|
{
|
||||||
|
label: $localize`:A variant of Trending videos based on the number of recent interactions, minus user history:Best`,
|
||||||
|
iconName: 'award',
|
||||||
|
value: 'best',
|
||||||
|
tooltip: $localize`Videos totalizing the most interactions for recent videos, minus user history`,
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`,
|
label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`,
|
||||||
iconName: 'flame',
|
iconName: 'flame',
|
||||||
|
|
|
@ -131,7 +131,7 @@ export class ServerService {
|
||||||
videos: {
|
videos: {
|
||||||
intervalDays: 0,
|
intervalDays: 0,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
enabled: [ 'hot', 'most-viewed', 'most-liked' ],
|
enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ],
|
||||||
default: 'most-viewed'
|
default: 'most-viewed'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,7 +71,8 @@ const icons = {
|
||||||
'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
|
'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
|
||||||
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
|
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
|
||||||
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
|
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
|
||||||
'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default
|
'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
|
||||||
|
'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GlobalIconName = keyof typeof icons
|
export type GlobalIconName = keyof typeof icons
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-award"><circle cx="12" cy="8" r="7"></circle><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"></polyline></svg>
|
After Width: | Height: | Size: 325 B |
|
@ -110,7 +110,8 @@ trending:
|
||||||
interval_days: 7 # Compute trending videos for the last x days
|
interval_days: 7 # Compute trending videos for the last x days
|
||||||
algorithms:
|
algorithms:
|
||||||
enabled:
|
enabled:
|
||||||
- 'hot' # adaptation of the Reddit 'Hot' algorithm
|
- 'best' # adaptation of Reddit's 'Best' algorithm (Hot minus History)
|
||||||
|
- 'hot' # adaptation of Reddit's 'Hot' algorithm
|
||||||
- 'most-viewed' # default, used initially by PeerTube as the trending page
|
- 'most-viewed' # default, used initially by PeerTube as the trending page
|
||||||
- 'most-liked'
|
- 'most-liked'
|
||||||
default: 'most-viewed'
|
default: 'most-viewed'
|
||||||
|
|
|
@ -108,7 +108,8 @@ trending:
|
||||||
interval_days: 7 # Compute trending videos for the last x days
|
interval_days: 7 # Compute trending videos for the last x days
|
||||||
algorithms:
|
algorithms:
|
||||||
enabled:
|
enabled:
|
||||||
- 'hot' # adaptation of the Reddit 'Hot' algorithm
|
- 'best' # adaptation of Reddit's 'Best' algorithm (Hot minus History)
|
||||||
|
- 'hot' # adaptation of Reddit's 'Hot' algorithm
|
||||||
- 'most-viewed' # default, used initially by PeerTube as the trending page
|
- 'most-viewed' # default, used initially by PeerTube as the trending page
|
||||||
- 'most-liked'
|
- 'most-liked'
|
||||||
default: 'most-viewed'
|
default: 'most-viewed'
|
||||||
|
|
|
@ -72,7 +72,7 @@ const SORTABLE_COLUMNS = {
|
||||||
FOLLOWERS: [ 'createdAt', 'state', 'score' ],
|
FOLLOWERS: [ 'createdAt', 'state', 'score' ],
|
||||||
FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ],
|
FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ],
|
||||||
|
|
||||||
VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot' ],
|
VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ],
|
||||||
|
|
||||||
// Don't forget to update peertube-search-index with the same values
|
// Don't forget to update peertube-search-index with the same values
|
||||||
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
|
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
|
||||||
|
|
|
@ -31,8 +31,8 @@ export type BuildVideosQueryOptions = {
|
||||||
|
|
||||||
videoPlaylistId?: number
|
videoPlaylistId?: number
|
||||||
|
|
||||||
|
trendingAlgorithm?: string // best, hot, or any other algorithm implemented
|
||||||
trendingDays?: number
|
trendingDays?: number
|
||||||
hot?: boolean
|
|
||||||
|
|
||||||
user?: MUserAccountId
|
user?: MUserAccountId
|
||||||
historyOfUser?: MUserId
|
historyOfUser?: MUserId
|
||||||
|
@ -252,7 +252,7 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions)
|
||||||
attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
|
attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
|
||||||
|
|
||||||
group = 'GROUP BY "video"."id"'
|
group = 'GROUP BY "video"."id"'
|
||||||
} else if (options.hot) {
|
} else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) {
|
||||||
/**
|
/**
|
||||||
* "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
|
* "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
|
||||||
* with fixed weights only applied to their log values.
|
* with fixed weights only applied to their log values.
|
||||||
|
@ -269,28 +269,39 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions)
|
||||||
*/
|
*/
|
||||||
const weights = {
|
const weights = {
|
||||||
like: 3,
|
like: 3,
|
||||||
dislike: 3,
|
dislike: -3,
|
||||||
view: 1 / 12,
|
view: 1 / 12,
|
||||||
comment: 2 // a comment takes more time than a like to do, but can be done multiple times
|
comment: 2, // a comment takes more time than a like to do, but can be done multiple times
|
||||||
|
history: -2
|
||||||
}
|
}
|
||||||
|
|
||||||
joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
|
joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
|
||||||
|
|
||||||
attributes.push(
|
let attribute =
|
||||||
`LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
|
`LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
|
||||||
`- LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
|
`+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
|
||||||
`+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
|
`+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
|
||||||
`+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
|
`+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
|
||||||
'+ (SELECT EXTRACT(epoch FROM "video"."publishedAt") / 47000) ' + // base score (in number of half-days)
|
'+ (SELECT EXTRACT(epoch FROM "video"."publishedAt") / 47000) ' // base score (in number of half-days)
|
||||||
'AS "score"'
|
|
||||||
|
if (options.trendingAlgorithm === 'best' && options.user) {
|
||||||
|
joins.push(
|
||||||
|
'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser'
|
||||||
)
|
)
|
||||||
|
replacements.bestUser = options.user.id
|
||||||
|
|
||||||
|
attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} `
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute += 'AS "score"'
|
||||||
|
attributes.push(attribute)
|
||||||
|
|
||||||
group = 'GROUP BY "video"."id"'
|
group = 'GROUP BY "video"."id"'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.historyOfUser) {
|
if (options.historyOfUser) {
|
||||||
joins.push('INNER JOIN "userVideoHistory" on "video"."id" = "userVideoHistory"."videoId"')
|
joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"')
|
||||||
|
|
||||||
and.push('"userVideoHistory"."userId" = :historyOfUser')
|
and.push('"userVideoHistory"."userId" = :historyOfUser')
|
||||||
replacements.historyOfUser = options.historyOfUser.id
|
replacements.historyOfUser = options.historyOfUser.id
|
||||||
|
@ -410,7 +421,7 @@ function buildOrder (value: string) {
|
||||||
|
|
||||||
if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
|
if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
|
||||||
|
|
||||||
if ([ 'trending', 'hot' ].includes(field.toLowerCase())) { // Sort by aggregation
|
if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
|
||||||
return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
|
return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1090,7 +1090,9 @@ export class VideoModel extends Model {
|
||||||
const trendingDays = options.sort.endsWith('trending')
|
const trendingDays = options.sort.endsWith('trending')
|
||||||
? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
|
? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
|
||||||
: undefined
|
: undefined
|
||||||
const hot = options.sort.endsWith('hot')
|
let trendingAlgorithm
|
||||||
|
if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
|
||||||
|
if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
|
||||||
|
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
|
|
||||||
|
@ -1120,7 +1122,7 @@ export class VideoModel extends Model {
|
||||||
user: options.user,
|
user: options.user,
|
||||||
historyOfUser: options.historyOfUser,
|
historyOfUser: options.historyOfUser,
|
||||||
trendingDays,
|
trendingDays,
|
||||||
hot,
|
trendingAlgorithm,
|
||||||
search: options.search
|
search: options.search
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -141,7 +141,7 @@ describe('Test config API validators', function () {
|
||||||
trending: {
|
trending: {
|
||||||
videos: {
|
videos: {
|
||||||
algorithms: {
|
algorithms: {
|
||||||
enabled: [ 'hot', 'most-viewed', 'most-liked' ],
|
enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ],
|
||||||
default: 'most-viewed'
|
default: 'most-viewed'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -375,7 +375,7 @@ describe('Test config', function () {
|
||||||
trending: {
|
trending: {
|
||||||
videos: {
|
videos: {
|
||||||
algorithms: {
|
algorithms: {
|
||||||
enabled: [ 'hot', 'most-viewed', 'most-liked' ],
|
enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ],
|
||||||
default: 'hot'
|
default: 'hot'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -363,6 +363,14 @@ describe('Test a single server', function () {
|
||||||
expect(videos.length).to.equal(2)
|
expect(videos.length).to.equal(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should list and sort by best in descending order', async function () {
|
||||||
|
const res = await getVideosListPagination(server.url, 0, 2, '-best')
|
||||||
|
|
||||||
|
const videos = res.body.data
|
||||||
|
expect(res.body.total).to.equal(6)
|
||||||
|
expect(videos.length).to.equal(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('Should update a video', async function () {
|
it('Should update a video', async function () {
|
||||||
const attributes = {
|
const attributes = {
|
||||||
name: 'my super video updated',
|
name: 'my super video updated',
|
||||||
|
|
|
@ -164,7 +164,7 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
|
||||||
trending: {
|
trending: {
|
||||||
videos: {
|
videos: {
|
||||||
algorithms: {
|
algorithms: {
|
||||||
enabled: [ 'hot', 'most-viewed', 'most-liked' ],
|
enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ],
|
||||||
default: 'hot'
|
default: 'hot'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,4 +8,5 @@ export type VideoSortField =
|
||||||
|
|
||||||
// trending sorts
|
// trending sorts
|
||||||
'trending' | '-trending' |
|
'trending' | '-trending' |
|
||||||
'hot' | '-hot'
|
'hot' | '-hot' |
|
||||||
|
'best' | '-best'
|
||||||
|
|
Loading…
Reference in New Issue