diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2021-02-02 12:59:41 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2021-02-04 09:04:47 +0100 |
commit | 3d4e112d16471703f51a542c0cc6e73a6f5db628 (patch) | |
tree | cb4a53a50f9bc14a87b62ccfa9d398feb4bbcbc8 | |
parent | f6267b610145033ee26ca8a4a7c2b97eca65072e (diff) | |
download | PeerTube-3d4e112d16471703f51a542c0cc6e73a6f5db628.tar.gz PeerTube-3d4e112d16471703f51a542c0cc6e73a6f5db628.tar.zst PeerTube-3d4e112d16471703f51a542c0cc6e73a6f5db628.zip |
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.
-rw-r--r-- | client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html | 2 | ||||
-rw-r--r-- | client/src/app/+videos/video-list/trending/video-trending-header.component.ts | 7 | ||||
-rw-r--r-- | client/src/app/core/server/server.service.ts | 2 | ||||
-rw-r--r-- | client/src/app/shared/shared-icons/global-icon.component.ts | 3 | ||||
-rw-r--r-- | client/src/assets/images/feather/award.svg | 1 | ||||
-rw-r--r-- | config/default.yaml | 3 | ||||
-rw-r--r-- | config/production.yaml.example | 3 | ||||
-rw-r--r-- | server/initializers/constants.ts | 2 | ||||
-rw-r--r-- | server/models/video/video-query-builder.ts | 33 | ||||
-rw-r--r-- | server/models/video/video.ts | 6 | ||||
-rw-r--r-- | server/tests/api/check-params/config.ts | 2 | ||||
-rw-r--r-- | server/tests/api/server/config.ts | 2 | ||||
-rw-r--r-- | server/tests/api/videos/single-server.ts | 8 | ||||
-rw-r--r-- | shared/extra-utils/server/config.ts | 2 | ||||
-rw-r--r-- | shared/models/videos/video-sort-field.type.ts | 3 |
15 files changed, 57 insertions, 22 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index 796aa12ed..48678a194 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html | |||
@@ -271,6 +271,7 @@ | |||
271 | <option i18n value="/videos/overview">Discover videos</option> | 271 | <option i18n value="/videos/overview">Discover videos</option> |
272 | <optgroup i18n-label label="Trending pages"> | 272 | <optgroup i18n-label label="Trending pages"> |
273 | <option i18n value="/videos/trending">Default trending page</option> | 273 | <option i18n value="/videos/trending">Default trending page</option> |
274 | <option i18n value="/videos/trending?alg=best" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('best')">Best videos</option> | ||
274 | <option i18n value="/videos/trending?alg=hot" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('hot')">Hot videos</option> | 275 | <option i18n value="/videos/trending?alg=hot" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('hot')">Hot videos</option> |
275 | <option i18n value="/videos/trending?alg=most-viewed" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('most-viewed')">Most viewed videos</option> | 276 | <option i18n value="/videos/trending?alg=most-viewed" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('most-viewed')">Most viewed videos</option> |
276 | <option i18n value="/videos/trending?alg=most-liked" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('most-liked')">Most liked videos</option> | 277 | <option i18n value="/videos/trending?alg=most-liked" [disabled]="!trendingVideosAlgorithmsEnabledIncludes('most-liked')">Most liked videos</option> |
@@ -288,6 +289,7 @@ | |||
288 | <label i18n for="trendingVideosAlgorithmsDefault">Default trending page</label> | 289 | <label i18n for="trendingVideosAlgorithmsDefault">Default trending page</label> |
289 | <div class="peertube-select-container"> | 290 | <div class="peertube-select-container"> |
290 | <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control"> | 291 | <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control"> |
292 | <option i18n value="best">Best videos</option> | ||
291 | <option i18n value="hot">Hot videos</option> | 293 | <option i18n value="hot">Hot videos</option> |
292 | <option i18n value="most-viewed">Most viewed videos</option> | 294 | <option i18n value="most-viewed">Most viewed videos</option> |
293 | <option i18n value="most-liked">Most liked videos</option> | 295 | <option i18n value="most-liked">Most liked videos</option> |
diff --git a/client/src/app/+videos/video-list/trending/video-trending-header.component.ts b/client/src/app/+videos/video-list/trending/video-trending-header.component.ts index 33eaa2c1e..a4a1e358f 100644 --- a/client/src/app/+videos/video-list/trending/video-trending-header.component.ts +++ b/client/src/app/+videos/video-list/trending/video-trending-header.component.ts | |||
@@ -36,6 +36,13 @@ export class VideoTrendingHeaderComponent extends VideoListHeaderComponent imple | |||
36 | 36 | ||
37 | this.buttons = [ | 37 | this.buttons = [ |
38 | { | 38 | { |
39 | label: $localize`:A variant of Trending videos based on the number of recent interactions, minus user history:Best`, | ||
40 | iconName: 'award', | ||
41 | value: 'best', | ||
42 | tooltip: $localize`Videos totalizing the most interactions for recent videos, minus user history`, | ||
43 | hidden: true | ||
44 | }, | ||
45 | { | ||
39 | label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`, | 46 | label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`, |
40 | iconName: 'flame', | 47 | iconName: 'flame', |
41 | value: 'hot', | 48 | value: 'hot', |
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index 39739afd0..11288fc54 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts | |||
@@ -131,7 +131,7 @@ export class ServerService { | |||
131 | videos: { | 131 | videos: { |
132 | intervalDays: 0, | 132 | intervalDays: 0, |
133 | algorithms: { | 133 | algorithms: { |
134 | enabled: [ 'hot', 'most-viewed', 'most-liked' ], | 134 | enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ], |
135 | default: 'most-viewed' | 135 | default: 'most-viewed' |
136 | } | 136 | } |
137 | } | 137 | } |
diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts index def488df0..3af517927 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts | |||
@@ -71,7 +71,8 @@ const icons = { | |||
71 | 'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default, | 71 | 'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default, |
72 | 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, | 72 | 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, |
73 | 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, | 73 | 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, |
74 | 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default | 74 | 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, |
75 | 'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default | ||
75 | } | 76 | } |
76 | 77 | ||
77 | export type GlobalIconName = keyof typeof icons | 78 | export type GlobalIconName = keyof typeof icons |
diff --git a/client/src/assets/images/feather/award.svg b/client/src/assets/images/feather/award.svg new file mode 100644 index 000000000..be70d5a13 --- /dev/null +++ b/client/src/assets/images/feather/award.svg | |||
@@ -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> \ No newline at end of file | |||
diff --git a/config/default.yaml b/config/default.yaml index 22488da99..95df2e06c 100644 --- a/config/default.yaml +++ b/config/default.yaml | |||
@@ -110,7 +110,8 @@ trending: | |||
110 | interval_days: 7 # Compute trending videos for the last x days | 110 | interval_days: 7 # Compute trending videos for the last x days |
111 | algorithms: | 111 | algorithms: |
112 | enabled: | 112 | enabled: |
113 | - 'hot' # adaptation of the Reddit 'Hot' algorithm | 113 | - 'best' # adaptation of Reddit's 'Best' algorithm (Hot minus History) |
114 | - 'hot' # adaptation of Reddit's 'Hot' algorithm | ||
114 | - 'most-viewed' # default, used initially by PeerTube as the trending page | 115 | - 'most-viewed' # default, used initially by PeerTube as the trending page |
115 | - 'most-liked' | 116 | - 'most-liked' |
116 | default: 'most-viewed' | 117 | default: 'most-viewed' |
diff --git a/config/production.yaml.example b/config/production.yaml.example index 66c981dd5..13a646918 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example | |||
@@ -108,7 +108,8 @@ trending: | |||
108 | interval_days: 7 # Compute trending videos for the last x days | 108 | interval_days: 7 # Compute trending videos for the last x days |
109 | algorithms: | 109 | algorithms: |
110 | enabled: | 110 | enabled: |
111 | - 'hot' # adaptation of the Reddit 'Hot' algorithm | 111 | - 'best' # adaptation of Reddit's 'Best' algorithm (Hot minus History) |
112 | - 'hot' # adaptation of Reddit's 'Hot' algorithm | ||
112 | - 'most-viewed' # default, used initially by PeerTube as the trending page | 113 | - 'most-viewed' # default, used initially by PeerTube as the trending page |
113 | - 'most-liked' | 114 | - 'most-liked' |
114 | default: 'most-viewed' | 115 | default: 'most-viewed' |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 0fab872a9..9d9b3966c 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -72,7 +72,7 @@ const SORTABLE_COLUMNS = { | |||
72 | FOLLOWERS: [ 'createdAt', 'state', 'score' ], | 72 | FOLLOWERS: [ 'createdAt', 'state', 'score' ], |
73 | FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ], | 73 | FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ], |
74 | 74 | ||
75 | VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot' ], | 75 | VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ], |
76 | 76 | ||
77 | // Don't forget to update peertube-search-index with the same values | 77 | // Don't forget to update peertube-search-index with the same values |
78 | VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], | 78 | VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], |
diff --git a/server/models/video/video-query-builder.ts b/server/models/video/video-query-builder.ts index e2145fb9a..822d0c89b 100644 --- a/server/models/video/video-query-builder.ts +++ b/server/models/video/video-query-builder.ts | |||
@@ -31,8 +31,8 @@ export type BuildVideosQueryOptions = { | |||
31 | 31 | ||
32 | videoPlaylistId?: number | 32 | videoPlaylistId?: number |
33 | 33 | ||
34 | trendingAlgorithm?: string // best, hot, or any other algorithm implemented | ||
34 | trendingDays?: number | 35 | trendingDays?: number |
35 | hot?: boolean | ||
36 | 36 | ||
37 | user?: MUserAccountId | 37 | user?: MUserAccountId |
38 | historyOfUser?: MUserId | 38 | historyOfUser?: MUserId |
@@ -252,7 +252,7 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) | |||
252 | attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"') | 252 | attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"') |
253 | 253 | ||
254 | group = 'GROUP BY "video"."id"' | 254 | group = 'GROUP BY "video"."id"' |
255 | } else if (options.hot) { | 255 | } else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) { |
256 | /** | 256 | /** |
257 | * "Hotness" is a measure based on absolute view/comment/like/dislike numbers, | 257 | * "Hotness" is a measure based on absolute view/comment/like/dislike numbers, |
258 | * with fixed weights only applied to their log values. | 258 | * with fixed weights only applied to their log values. |
@@ -269,28 +269,39 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) | |||
269 | */ | 269 | */ |
270 | const weights = { | 270 | const weights = { |
271 | like: 3, | 271 | like: 3, |
272 | dislike: 3, | 272 | dislike: -3, |
273 | view: 1 / 12, | 273 | view: 1 / 12, |
274 | comment: 2 // a comment takes more time than a like to do, but can be done multiple times | 274 | comment: 2, // a comment takes more time than a like to do, but can be done multiple times |
275 | history: -2 | ||
275 | } | 276 | } |
276 | 277 | ||
277 | joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"') | 278 | joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"') |
278 | 279 | ||
279 | attributes.push( | 280 | let attribute = |
280 | `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+) | 281 | `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+) |
281 | `- LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-) | 282 | `+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-) |
282 | `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+) | 283 | `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+) |
283 | `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+) | 284 | `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+) |
284 | '+ (SELECT EXTRACT(epoch FROM "video"."publishedAt") / 47000) ' + // base score (in number of half-days) | 285 | '+ (SELECT EXTRACT(epoch FROM "video"."publishedAt") / 47000) ' // base score (in number of half-days) |
285 | 'AS "score"' | 286 | |
286 | ) | 287 | if (options.trendingAlgorithm === 'best' && options.user) { |
288 | joins.push( | ||
289 | 'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser' | ||
290 | ) | ||
291 | replacements.bestUser = options.user.id | ||
292 | |||
293 | attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} ` | ||
294 | } | ||
295 | |||
296 | attribute += 'AS "score"' | ||
297 | attributes.push(attribute) | ||
287 | 298 | ||
288 | group = 'GROUP BY "video"."id"' | 299 | group = 'GROUP BY "video"."id"' |
289 | } | 300 | } |
290 | } | 301 | } |
291 | 302 | ||
292 | if (options.historyOfUser) { | 303 | if (options.historyOfUser) { |
293 | joins.push('INNER JOIN "userVideoHistory" on "video"."id" = "userVideoHistory"."videoId"') | 304 | joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"') |
294 | 305 | ||
295 | and.push('"userVideoHistory"."userId" = :historyOfUser') | 306 | and.push('"userVideoHistory"."userId" = :historyOfUser') |
296 | replacements.historyOfUser = options.historyOfUser.id | 307 | replacements.historyOfUser = options.historyOfUser.id |
@@ -410,7 +421,7 @@ function buildOrder (value: string) { | |||
410 | 421 | ||
411 | if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' | 422 | if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' |
412 | 423 | ||
413 | if ([ 'trending', 'hot' ].includes(field.toLowerCase())) { // Sort by aggregation | 424 | if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation |
414 | return `ORDER BY "score" ${direction}, "video"."views" ${direction}` | 425 | return `ORDER BY "score" ${direction}, "video"."views" ${direction}` |
415 | } | 426 | } |
416 | 427 | ||
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index ea6c9d44b..0ecb8d600 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -1090,7 +1090,9 @@ export class VideoModel extends Model { | |||
1090 | const trendingDays = options.sort.endsWith('trending') | 1090 | const trendingDays = options.sort.endsWith('trending') |
1091 | ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS | 1091 | ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS |
1092 | : undefined | 1092 | : undefined |
1093 | const hot = options.sort.endsWith('hot') | 1093 | let trendingAlgorithm |
1094 | if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot' | ||
1095 | if (options.sort.endsWith('best')) trendingAlgorithm = 'best' | ||
1094 | 1096 | ||
1095 | const serverActor = await getServerActor() | 1097 | const serverActor = await getServerActor() |
1096 | 1098 | ||
@@ -1120,7 +1122,7 @@ export class VideoModel extends Model { | |||
1120 | user: options.user, | 1122 | user: options.user, |
1121 | historyOfUser: options.historyOfUser, | 1123 | historyOfUser: options.historyOfUser, |
1122 | trendingDays, | 1124 | trendingDays, |
1123 | hot, | 1125 | trendingAlgorithm, |
1124 | search: options.search | 1126 | search: options.search |
1125 | } | 1127 | } |
1126 | 1128 | ||
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index e6309b5f7..d6c20f7af 100644 --- a/server/tests/api/check-params/config.ts +++ b/server/tests/api/check-params/config.ts | |||
@@ -141,7 +141,7 @@ describe('Test config API validators', function () { | |||
141 | trending: { | 141 | trending: { |
142 | videos: { | 142 | videos: { |
143 | algorithms: { | 143 | algorithms: { |
144 | enabled: [ 'hot', 'most-viewed', 'most-liked' ], | 144 | enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ], |
145 | default: 'most-viewed' | 145 | default: 'most-viewed' |
146 | } | 146 | } |
147 | } | 147 | } |
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index e5bab0b77..26df8373e 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts | |||
@@ -375,7 +375,7 @@ describe('Test config', function () { | |||
375 | trending: { | 375 | trending: { |
376 | videos: { | 376 | videos: { |
377 | algorithms: { | 377 | algorithms: { |
378 | enabled: [ 'hot', 'most-viewed', 'most-liked' ], | 378 | enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ], |
379 | default: 'hot' | 379 | default: 'hot' |
380 | } | 380 | } |
381 | } | 381 | } |
diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts index 52c6800c1..da90223b8 100644 --- a/server/tests/api/videos/single-server.ts +++ b/server/tests/api/videos/single-server.ts | |||
@@ -363,6 +363,14 @@ describe('Test a single server', function () { | |||
363 | expect(videos.length).to.equal(2) | 363 | expect(videos.length).to.equal(2) |
364 | }) | 364 | }) |
365 | 365 | ||
366 | it('Should list and sort by best in descending order', async function () { | ||
367 | const res = await getVideosListPagination(server.url, 0, 2, '-best') | ||
368 | |||
369 | const videos = res.body.data | ||
370 | expect(res.body.total).to.equal(6) | ||
371 | expect(videos.length).to.equal(2) | ||
372 | }) | ||
373 | |||
366 | it('Should update a video', async function () { | 374 | it('Should update a video', async function () { |
367 | const attributes = { | 375 | const attributes = { |
368 | name: 'my super video updated', | 376 | name: 'my super video updated', |
diff --git a/shared/extra-utils/server/config.ts b/shared/extra-utils/server/config.ts index 8998da8b6..db5a473ca 100644 --- a/shared/extra-utils/server/config.ts +++ b/shared/extra-utils/server/config.ts | |||
@@ -164,7 +164,7 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti | |||
164 | trending: { | 164 | trending: { |
165 | videos: { | 165 | videos: { |
166 | algorithms: { | 166 | algorithms: { |
167 | enabled: [ 'hot', 'most-viewed', 'most-liked' ], | 167 | enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ], |
168 | default: 'hot' | 168 | default: 'hot' |
169 | } | 169 | } |
170 | } | 170 | } |
diff --git a/shared/models/videos/video-sort-field.type.ts b/shared/models/videos/video-sort-field.type.ts index 97687f84b..5073848b8 100644 --- a/shared/models/videos/video-sort-field.type.ts +++ b/shared/models/videos/video-sort-field.type.ts | |||
@@ -8,4 +8,5 @@ export type VideoSortField = | |||
8 | 8 | ||
9 | // trending sorts | 9 | // trending sorts |
10 | 'trending' | '-trending' | | 10 | 'trending' | '-trending' | |
11 | 'hot' | '-hot' | 11 | 'hot' | '-hot' | |
12 | 'best' | '-best' | ||