2023-10-06 14:24:25 +09:00
|
|
|
/*
|
2024-02-14 00:59:27 +09:00
|
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
2023-10-06 14:24:25 +09:00
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
|
|
import * as Redis from 'ioredis';
|
2023-11-26 10:05:56 +09:00
|
|
|
import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js';
|
2023-10-06 14:24:25 +09:00
|
|
|
import { DI } from '@/di-symbols.js';
|
|
|
|
import { bindThis } from '@/decorators.js';
|
|
|
|
|
2023-10-06 16:10:59 +09:00
|
|
|
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
2023-11-26 10:05:56 +09:00
|
|
|
export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
2023-10-06 18:30:08 +09:00
|
|
|
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
2023-10-07 12:05:17 +09:00
|
|
|
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
|
2023-10-06 16:10:59 +09:00
|
|
|
|
2023-12-04 20:33:11 +09:00
|
|
|
const featuredEpoc = new Date('2023-01-01T00:00:00Z').getTime();
|
|
|
|
|
2023-10-06 14:24:25 +09:00
|
|
|
@Injectable()
|
|
|
|
export class FeaturedService {
|
|
|
|
constructor(
|
|
|
|
@Inject(DI.redis)
|
2023-10-06 16:58:38 +09:00
|
|
|
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
2023-10-06 14:24:25 +09:00
|
|
|
) {
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2023-10-06 16:58:38 +09:00
|
|
|
private getCurrentWindow(windowRange: number): number {
|
2023-12-04 20:33:11 +09:00
|
|
|
const passed = new Date().getTime() - featuredEpoc;
|
2023-10-06 16:58:38 +09:00
|
|
|
return Math.floor(passed / windowRange);
|
2023-10-06 14:24:25 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2023-10-06 16:58:38 +09:00
|
|
|
private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise<void> {
|
|
|
|
const currentWindow = this.getCurrentWindow(windowRange);
|
2023-10-06 14:24:25 +09:00
|
|
|
const redisTransaction = this.redisClient.multi();
|
|
|
|
redisTransaction.zincrby(
|
2023-10-06 16:58:38 +09:00
|
|
|
`${name}:${currentWindow}`,
|
|
|
|
score,
|
|
|
|
element);
|
2023-10-06 14:24:25 +09:00
|
|
|
redisTransaction.expire(
|
2023-10-06 16:58:38 +09:00
|
|
|
`${name}:${currentWindow}`,
|
|
|
|
(windowRange * 3) / 1000,
|
2023-10-06 14:24:25 +09:00
|
|
|
'NX'); // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
|
|
|
await redisTransaction.exec();
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2023-10-09 13:22:58 +09:00
|
|
|
private async getRankingOf(name: string, windowRange: number, threshold: number): Promise<string[]> {
|
2023-10-06 16:58:38 +09:00
|
|
|
const currentWindow = this.getCurrentWindow(windowRange);
|
2023-10-06 14:24:25 +09:00
|
|
|
const previousWindow = currentWindow - 1;
|
|
|
|
|
2023-10-09 15:34:03 +09:00
|
|
|
const redisPipeline = this.redisClient.pipeline();
|
|
|
|
redisPipeline.zrange(
|
|
|
|
`${name}:${currentWindow}`, 0, threshold, 'REV', 'WITHSCORES');
|
|
|
|
redisPipeline.zrange(
|
|
|
|
`${name}:${previousWindow}`, 0, threshold, 'REV', 'WITHSCORES');
|
2023-10-27 18:37:17 +09:00
|
|
|
const [currentRankingResult, previousRankingResult] = await redisPipeline.exec().then(result => result ? result.map(r => (r[1] ?? []) as string[]) : [[], []]);
|
2023-10-06 14:24:25 +09:00
|
|
|
|
2023-10-06 16:58:38 +09:00
|
|
|
const ranking = new Map<string, number>();
|
2023-10-06 14:24:25 +09:00
|
|
|
for (let i = 0; i < currentRankingResult.length; i += 2) {
|
|
|
|
const noteId = currentRankingResult[i];
|
|
|
|
const score = parseInt(currentRankingResult[i + 1], 10);
|
|
|
|
ranking.set(noteId, score);
|
|
|
|
}
|
|
|
|
for (let i = 0; i < previousRankingResult.length; i += 2) {
|
|
|
|
const noteId = previousRankingResult[i];
|
|
|
|
const score = parseInt(previousRankingResult[i + 1], 10);
|
|
|
|
const exist = ranking.get(noteId);
|
|
|
|
if (exist != null) {
|
|
|
|
ranking.set(noteId, (exist + score) / 2);
|
|
|
|
} else {
|
|
|
|
ranking.set(noteId, score);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return Array.from(ranking.keys());
|
|
|
|
}
|
|
|
|
|
2023-12-21 11:34:19 +09:00
|
|
|
@bindThis
|
|
|
|
private async removeFromRanking(name: string, windowRange: number, element: string): Promise<void> {
|
|
|
|
const currentWindow = this.getCurrentWindow(windowRange);
|
|
|
|
const previousWindow = currentWindow - 1;
|
|
|
|
|
|
|
|
const redisPipeline = this.redisClient.pipeline();
|
|
|
|
redisPipeline.zrem(`${name}:${currentWindow}`, element);
|
|
|
|
redisPipeline.zrem(`${name}:${previousWindow}`, element);
|
|
|
|
await redisPipeline.exec();
|
|
|
|
}
|
|
|
|
|
2023-10-06 14:24:25 +09:00
|
|
|
@bindThis
|
2023-10-06 17:01:06 +09:00
|
|
|
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> {
|
2023-10-06 16:58:38 +09:00
|
|
|
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
|
|
|
}
|
2023-10-06 14:24:25 +09:00
|
|
|
|
2023-11-26 10:05:56 +09:00
|
|
|
@bindThis
|
|
|
|
public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise<void> {
|
|
|
|
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score);
|
|
|
|
}
|
|
|
|
|
2023-10-06 16:58:38 +09:00
|
|
|
@bindThis
|
2023-10-06 18:30:08 +09:00
|
|
|
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
|
2023-10-06 16:58:38 +09:00
|
|
|
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
|
|
|
}
|
2023-10-06 14:24:25 +09:00
|
|
|
|
2023-10-06 18:30:08 +09:00
|
|
|
@bindThis
|
|
|
|
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> {
|
|
|
|
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
|
|
|
}
|
|
|
|
|
2023-10-07 12:05:17 +09:00
|
|
|
@bindThis
|
|
|
|
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
|
|
|
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
|
|
|
}
|
|
|
|
|
2023-10-06 16:58:38 +09:00
|
|
|
@bindThis
|
2023-10-09 13:22:58 +09:00
|
|
|
public getGlobalNotesRanking(threshold: number): Promise<MiNote['id'][]> {
|
|
|
|
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold);
|
2023-10-06 16:58:38 +09:00
|
|
|
}
|
2023-10-06 14:24:25 +09:00
|
|
|
|
2023-11-26 10:05:56 +09:00
|
|
|
@bindThis
|
|
|
|
public getGalleryPostsRanking(threshold: number): Promise<MiGalleryPost['id'][]> {
|
|
|
|
return this.getRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, threshold);
|
|
|
|
}
|
|
|
|
|
2023-10-06 16:58:38 +09:00
|
|
|
@bindThis
|
2023-10-09 13:22:58 +09:00
|
|
|
public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> {
|
|
|
|
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold);
|
2023-10-06 14:24:25 +09:00
|
|
|
}
|
2023-10-06 18:30:08 +09:00
|
|
|
|
|
|
|
@bindThis
|
2023-10-09 13:22:58 +09:00
|
|
|
public getPerUserNotesRanking(userId: MiUser['id'], threshold: number): Promise<MiNote['id'][]> {
|
|
|
|
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, threshold);
|
2023-10-06 18:30:08 +09:00
|
|
|
}
|
2023-10-07 12:05:17 +09:00
|
|
|
|
|
|
|
@bindThis
|
2023-10-09 13:22:58 +09:00
|
|
|
public getHashtagsRanking(threshold: number): Promise<string[]> {
|
|
|
|
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, threshold);
|
2023-10-07 12:05:17 +09:00
|
|
|
}
|
2023-12-21 11:34:19 +09:00
|
|
|
|
|
|
|
@bindThis
|
|
|
|
public removeHashtagsFromRanking(hashtag: string): Promise<void> {
|
|
|
|
return this.removeFromRanking('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag);
|
|
|
|
}
|
2023-10-06 14:24:25 +09:00
|
|
|
}
|