summaryrefslogtreecommitdiff
path: root/packages/backend/src/core/FeaturedService.ts
blob: 78309e42e5d417c7888f51ad2664139e0fc6ec6a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
/*
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 * SPDX-License-Identifier: AGPL-3.0-only
 */

import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { MiNote } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';

const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと

@Injectable()
export class FeaturedService {
	constructor(
		@Inject(DI.redis)
		private redisClient: Redis.Redis,
	) {
	}

	@bindThis
	private getCurrentPerUserFriendRankingWindow(): number {
		const passed = new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime();
		return Math.floor(passed / (1000 * 60 * 60 * 24 * 7)); // 1週間ごと
	}

	@bindThis
	private getCurrentGlobalNotesRankingWindow(): number {
		const passed = new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime();
		return Math.floor(passed / GLOBAL_NOTES_RANKING_WINDOW);
	}

	@bindThis
	public async updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> {
		// TODO: フォロワー数の多い人が常にランキング上位になるのを防ぎたい
		const currentWindow = this.getCurrentGlobalNotesRankingWindow();
		const redisTransaction = this.redisClient.multi();
		redisTransaction.zincrby(
			`featuredGlobalNotesRanking:${currentWindow}`,
			score.toString(),
			noteId);
		redisTransaction.expire(
			`featuredGlobalNotesRanking:${currentWindow}`,
			(GLOBAL_NOTES_RANKING_WINDOW * 3) / 1000,
			'NX'); // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
		await redisTransaction.exec();
	}

	@bindThis
	public async updateInChannelNotesRanking(noteId: MiNote['id'], channelId: MiNote['channelId'], score = 1): Promise<void> {
		const currentWindow = this.getCurrentGlobalNotesRankingWindow();
		const redisTransaction = this.redisClient.multi();
		redisTransaction.zincrby(
			`featuredInChannelNotesRanking:${channelId}:${currentWindow}`,
			score.toString(),
			noteId);
		redisTransaction.expire(
			`featuredInChannelNotesRanking:${channelId}:${currentWindow}`,
			(GLOBAL_NOTES_RANKING_WINDOW * 3) / 1000,
			'NX'); // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
		await redisTransaction.exec();
	}

	@bindThis
	public async getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
		const currentWindow = this.getCurrentGlobalNotesRankingWindow();
		const previousWindow = currentWindow - 1;

		const [currentRankingResult, previousRankingResult] = await Promise.all([
			this.redisClient.zrange(
				`featuredGlobalNotesRanking:${currentWindow}`, 0, limit, 'REV', 'WITHSCORES'),
			this.redisClient.zrange(
				`featuredGlobalNotesRanking:${previousWindow}`, 0, limit, 'REV', 'WITHSCORES'),
		]);

		const ranking = new Map<MiNote['id'], number>();
		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());
	}

	@bindThis
	public async getInChannelNotesRanking(channelId: MiNote['channelId'], limit: number): Promise<MiNote['id'][]> {
		const currentWindow = this.getCurrentGlobalNotesRankingWindow();
		const previousWindow = currentWindow - 1;

		const [currentRankingResult, previousRankingResult] = await Promise.all([
			this.redisClient.zrange(
				`featuredInChannelNotesRanking:${channelId}:${currentWindow}`, 0, limit, 'REV', 'WITHSCORES'),
			this.redisClient.zrange(
				`featuredInChannelNotesRanking:${channelId}:${previousWindow}`, 0, limit, 'REV', 'WITHSCORES'),
		]);

		const ranking = new Map<MiNote['id'], number>();
		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());
	}
}