Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38e997cba2 |
@ -26,12 +26,8 @@ val IDX_REPLAY_IDS_PAIRS: Index = Internal.createIndex(DSL.name("idx_replay_ids_
|
||||
val IDX_SCORES_BEATMAP_ID: Index = Internal.createIndex(DSL.name("idx_scores_beatmap_id"), Scores.SCORES, arrayOf(Scores.SCORES.BEATMAP_ID), false)
|
||||
val IDX_SCORES_BEATMAP_ID_REPLAY_ID: Index = Internal.createIndex(DSL.name("idx_scores_beatmap_id_replay_id"), Scores.SCORES, arrayOf(Scores.SCORES.BEATMAP_ID, Scores.SCORES.REPLAY_ID), false)
|
||||
val IDX_SCORES_BEATMAP_ID_REPLAY_ID_UR: Index = Internal.createIndex(DSL.name("idx_scores_beatmap_id_replay_id_ur"), Scores.SCORES, arrayOf(Scores.SCORES.BEATMAP_ID, Scores.SCORES.REPLAY_ID, Scores.SCORES.UR), false)
|
||||
val IDX_SCORES_IS_BANNED: Index = Internal.createIndex(DSL.name("idx_scores_is_banned"), Scores.SCORES, arrayOf(Scores.SCORES.IS_BANNED), false)
|
||||
val IDX_SCORES_JUDGEMENTS_SCORE_ID: Index = Internal.createIndex(DSL.name("idx_scores_judgements_score_id"), ScoresJudgements.SCORES_JUDGEMENTS, arrayOf(ScoresJudgements.SCORES_JUDGEMENTS.SCORE_ID), false)
|
||||
val IDX_SCORES_KEYPRESSES_STANDARD_DEVIATION_ADJUSTED: Index = Internal.createIndex(DSL.name("idx_scores_keypresses_standard_deviation_adjusted"), Scores.SCORES, arrayOf(Scores.SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED), false)
|
||||
val IDX_SCORES_PP: Index = Internal.createIndex(DSL.name("idx_scores_pp"), Scores.SCORES, arrayOf(Scores.SCORES.PP), false)
|
||||
val IDX_SCORES_REPLAY_ID: Index = Internal.createIndex(DSL.name("idx_scores_replay_id"), Scores.SCORES, arrayOf(Scores.SCORES.REPLAY_ID), false)
|
||||
val IDX_SCORES_SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED: Index = Internal.createIndex(DSL.name("idx_scores_sliderend_release_standard_deviation_adjusted"), Scores.SCORES, arrayOf(Scores.SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED), false)
|
||||
val IDX_SCORES_UR: Index = Internal.createIndex(DSL.name("idx_scores_ur"), Scores.SCORES, arrayOf(Scores.SCORES.UR), false)
|
||||
val IDX_SCORES_USER_ID: Index = Internal.createIndex(DSL.name("idx_scores_user_id"), Scores.SCORES, arrayOf(Scores.SCORES.USER_ID), false)
|
||||
val IDX_USERS_IS_BANNED_FALSE: Index = Internal.createIndex(DSL.name("idx_users_is_banned_false"), Users.USERS, arrayOf(Users.USERS.IS_BANNED), false)
|
||||
|
||||
@ -8,11 +8,7 @@ import com.nisemoe.generated.Public
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_BEATMAP_ID
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_BEATMAP_ID_REPLAY_ID
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_BEATMAP_ID_REPLAY_ID_UR
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_IS_BANNED
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_KEYPRESSES_STANDARD_DEVIATION_ADJUSTED
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_PP
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_REPLAY_ID
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_UR
|
||||
import com.nisemoe.generated.indexes.IDX_SCORES_USER_ID
|
||||
import com.nisemoe.generated.keys.REPLAY_ID_UNIQUE
|
||||
@ -321,11 +317,6 @@ open class Scores(
|
||||
*/
|
||||
val JUDGEMENTS: TableField<ScoresRecord, ByteArray?> = createField(DSL.name("judgements"), SQLDataType.BLOB, this, "")
|
||||
|
||||
/**
|
||||
* The column <code>public.scores.leaderboard_rank</code>.
|
||||
*/
|
||||
val LEADERBOARD_RANK: TableField<ScoresRecord, Long?> = createField(DSL.name("leaderboard_rank"), SQLDataType.BIGINT, this, "")
|
||||
|
||||
private constructor(alias: Name, aliased: Table<ScoresRecord>?): this(alias, null, null, null, aliased, null, null)
|
||||
private constructor(alias: Name, aliased: Table<ScoresRecord>?, parameters: Array<Field<*>?>?): this(alias, null, null, null, aliased, parameters, null)
|
||||
private constructor(alias: Name, aliased: Table<ScoresRecord>?, where: Condition?): this(alias, null, null, null, aliased, null, where)
|
||||
@ -358,7 +349,7 @@ open class Scores(
|
||||
override fun `as`(alias: Table<*>): ScoresPath = ScoresPath(alias.qualifiedName, this)
|
||||
}
|
||||
override fun getSchema(): Schema? = if (aliased()) null else Public.PUBLIC
|
||||
override fun getIndexes(): List<Index> = listOf(IDX_SCORES_BEATMAP_ID, IDX_SCORES_BEATMAP_ID_REPLAY_ID, IDX_SCORES_BEATMAP_ID_REPLAY_ID_UR, IDX_SCORES_IS_BANNED, IDX_SCORES_KEYPRESSES_STANDARD_DEVIATION_ADJUSTED, IDX_SCORES_PP, IDX_SCORES_REPLAY_ID, IDX_SCORES_SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, IDX_SCORES_UR, IDX_SCORES_USER_ID)
|
||||
override fun getIndexes(): List<Index> = listOf(IDX_SCORES_BEATMAP_ID, IDX_SCORES_BEATMAP_ID_REPLAY_ID, IDX_SCORES_BEATMAP_ID_REPLAY_ID_UR, IDX_SCORES_REPLAY_ID, IDX_SCORES_UR, IDX_SCORES_USER_ID)
|
||||
override fun getPrimaryKey(): UniqueKey<ScoresRecord> = SCORES_PKEY
|
||||
override fun getUniqueKeys(): List<UniqueKey<ScoresRecord>> = listOf(REPLAY_ID_UNIQUE)
|
||||
|
||||
|
||||
@ -205,10 +205,6 @@ open class ScoresRecord private constructor() : UpdatableRecordImpl<ScoresRecord
|
||||
set(value): Unit = set(45, value)
|
||||
get(): ByteArray? = get(45) as ByteArray?
|
||||
|
||||
open var leaderboardRank: Long?
|
||||
set(value): Unit = set(46, value)
|
||||
get(): Long? = get(46) as Long?
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Primary key information
|
||||
// -------------------------------------------------------------------------
|
||||
@ -218,7 +214,7 @@ open class ScoresRecord private constructor() : UpdatableRecordImpl<ScoresRecord
|
||||
/**
|
||||
* Create a detached, initialised ScoresRecord
|
||||
*/
|
||||
constructor(id: Int? = null, beatmapId: Int? = null, count_100: Int? = null, count_300: Int? = null, count_50: Int? = null, countMiss: Int? = null, date: LocalDateTime? = null, maxCombo: Int? = null, mods: Int? = null, perfect: Boolean? = null, pp: Double? = null, rank: String? = null, replayAvailable: Boolean? = null, replayId: Long? = null, score: Long? = null, userId: Long? = null, replay: ByteArray? = null, ur: Double? = null, frametime: Double? = null, edgeHits: Int? = null, snaps: Int? = null, isBanned: Boolean? = null, adjustedUr: Double? = null, meanError: Double? = null, errorVariance: Double? = null, errorStandardDeviation: Double? = null, minimumError: Double? = null, maximumError: Double? = null, errorRange: Double? = null, errorCoefficientOfVariation: Double? = null, errorKurtosis: Double? = null, errorSkewness: Double? = null, sentDiscordNotification: Boolean? = null, addedAt: OffsetDateTime? = null, version: Int? = null, keypressesTimes: Array<Double?>? = null, keypressesMedian: Double? = null, keypressesStandardDeviation: Double? = null, sliderendReleaseTimes: Array<Double?>? = null, sliderendReleaseMedian: Double? = null, sliderendReleaseStandardDeviation: Double? = null, keypressesMedianAdjusted: Double? = null, keypressesStandardDeviationAdjusted: Double? = null, sliderendReleaseMedianAdjusted: Double? = null, sliderendReleaseStandardDeviationAdjusted: Double? = null, judgements: ByteArray? = null, leaderboardRank: Long? = null): this() {
|
||||
constructor(id: Int? = null, beatmapId: Int? = null, count_100: Int? = null, count_300: Int? = null, count_50: Int? = null, countMiss: Int? = null, date: LocalDateTime? = null, maxCombo: Int? = null, mods: Int? = null, perfect: Boolean? = null, pp: Double? = null, rank: String? = null, replayAvailable: Boolean? = null, replayId: Long? = null, score: Long? = null, userId: Long? = null, replay: ByteArray? = null, ur: Double? = null, frametime: Double? = null, edgeHits: Int? = null, snaps: Int? = null, isBanned: Boolean? = null, adjustedUr: Double? = null, meanError: Double? = null, errorVariance: Double? = null, errorStandardDeviation: Double? = null, minimumError: Double? = null, maximumError: Double? = null, errorRange: Double? = null, errorCoefficientOfVariation: Double? = null, errorKurtosis: Double? = null, errorSkewness: Double? = null, sentDiscordNotification: Boolean? = null, addedAt: OffsetDateTime? = null, version: Int? = null, keypressesTimes: Array<Double?>? = null, keypressesMedian: Double? = null, keypressesStandardDeviation: Double? = null, sliderendReleaseTimes: Array<Double?>? = null, sliderendReleaseMedian: Double? = null, sliderendReleaseStandardDeviation: Double? = null, keypressesMedianAdjusted: Double? = null, keypressesStandardDeviationAdjusted: Double? = null, sliderendReleaseMedianAdjusted: Double? = null, sliderendReleaseStandardDeviationAdjusted: Double? = null, judgements: ByteArray? = null): this() {
|
||||
this.id = id
|
||||
this.beatmapId = beatmapId
|
||||
this.count_100 = count_100
|
||||
@ -265,7 +261,6 @@ open class ScoresRecord private constructor() : UpdatableRecordImpl<ScoresRecord
|
||||
this.sliderendReleaseMedianAdjusted = sliderendReleaseMedianAdjusted
|
||||
this.sliderendReleaseStandardDeviationAdjusted = sliderendReleaseStandardDeviationAdjusted
|
||||
this.judgements = judgements
|
||||
this.leaderboardRank = leaderboardRank
|
||||
resetChangedOnNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,8 +56,7 @@ data class SuspiciousScoreEntry(
|
||||
val beatmap_star_rating: Double,
|
||||
val pp: Double,
|
||||
val frametime: Double,
|
||||
val ur: Double,
|
||||
val leaderboard_rank: Long?,
|
||||
val ur: Double
|
||||
)
|
||||
|
||||
data class SimilarReplayEntry(
|
||||
@ -65,8 +64,6 @@ data class SimilarReplayEntry(
|
||||
val replay_id_2: Long,
|
||||
val user_id_1: Long,
|
||||
val user_id_2: Long,
|
||||
val user_banned_1: Boolean,
|
||||
val user_banned_2: Boolean,
|
||||
val username_1: String,
|
||||
val username_2: String,
|
||||
val beatmap_beatmapset_id: Long,
|
||||
@ -74,8 +71,6 @@ data class SimilarReplayEntry(
|
||||
val replay_date_2: String,
|
||||
val replay_pp_1: Double,
|
||||
val replay_pp_2: Double,
|
||||
val replay_leaderboard_rank_1: Long?,
|
||||
val replay_leaderboard_rank_2: Long?,
|
||||
val beatmap_id: Long,
|
||||
val beatmap_title: String,
|
||||
val beatmap_star_rating: Double,
|
||||
@ -180,7 +175,6 @@ data class ReplayData(
|
||||
val pp: Double?,
|
||||
val perfect: Boolean,
|
||||
val max_combo: Int,
|
||||
val leaderboard_rank: Long?,
|
||||
|
||||
val count_300: Int,
|
||||
val count_100: Int,
|
||||
@ -204,31 +198,6 @@ data class ReplayData(
|
||||
|
||||
}
|
||||
|
||||
// Contains everything needed to reconstruct a replay (.osr) file.
|
||||
// We currently do not store some of these - these have been marked.
|
||||
data class EncodedReplayData(
|
||||
val gameMode: Byte,
|
||||
val gameVersion: Int,
|
||||
val beatmapHash: String,
|
||||
val username: String,
|
||||
val replayHash: String, // We do not store - maybe we can reconstruct?
|
||||
val count300: Short,
|
||||
val count100: Short,
|
||||
val count50: Short,
|
||||
val countGeki: Short, // We do not store - probably should
|
||||
val countKatu: Short, // We do not store - probably should
|
||||
val countMisses: Short,
|
||||
val totalScore: Int,
|
||||
val greatestCombo: Short,
|
||||
val perfect: Byte,
|
||||
val mods: Int,
|
||||
val lifeBar: String, // We do not store, and maybe shouldn't?
|
||||
val timeStamp: Long,
|
||||
val replayLength: ByteArray, // We do not store - could be calculated?
|
||||
val scoreId: Long,
|
||||
val additionalInformation: Double, // We do not store - probably not needed
|
||||
)
|
||||
|
||||
data class DistributionEntry(
|
||||
val countMiss: Double,
|
||||
val count300: Double,
|
||||
|
||||
@ -76,7 +76,7 @@ class BanlistController(
|
||||
.where(USERS.IS_BANNED.eq(true))
|
||||
.orderBy(USERS.APPROX_BAN_DATE.desc())
|
||||
.limit(MAX_BANLIST_ENTRIES_PER_PAGE)
|
||||
.offset((request.page - 1) * MAX_BANLIST_ENTRIES_PER_PAGE)
|
||||
.offset((request.page - 1) * 10)
|
||||
.fetch()
|
||||
.map {
|
||||
BanlistEntry(
|
||||
|
||||
@ -8,15 +8,15 @@ data class HealthResponse(
|
||||
val healthy: Boolean,
|
||||
)
|
||||
|
||||
val healthResponse = HealthResponse(
|
||||
healthy = true,
|
||||
)
|
||||
|
||||
@RestController
|
||||
class HealthController {
|
||||
companion object {
|
||||
val HEALTH = HealthResponse(
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
fun healthCheck(): ResponseEntity<HealthResponse> = ResponseEntity.ok(HEALTH)
|
||||
fun healthCheck(): ResponseEntity<HealthResponse> {
|
||||
return ResponseEntity.ok(healthResponse)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,14 +8,12 @@ data class VersionResponse(
|
||||
val version: String,
|
||||
)
|
||||
|
||||
val versionResponse = VersionResponse(
|
||||
version = "v20250213",
|
||||
)
|
||||
|
||||
@RestController
|
||||
class VersionController {
|
||||
companion object {
|
||||
val VERSION = VersionResponse(
|
||||
version = "v20250702",
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/version")
|
||||
fun getVersion(): ResponseEntity<VersionResponse> = ResponseEntity.ok(VERSION)
|
||||
}
|
||||
fun getVersion(): ResponseEntity<VersionResponse> = ResponseEntity.ok(versionResponse)
|
||||
}
|
||||
@ -10,7 +10,6 @@ import com.nisemoe.nise.osu.OsuApi
|
||||
import com.nisemoe.nise.service.AuthService
|
||||
import com.nisemoe.nise.service.CompressReplay
|
||||
import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream
|
||||
import org.jetbrains.kotlinx.dataframe.impl.asList
|
||||
import org.jooq.Condition
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
@ -40,32 +39,6 @@ class ScoreService(
|
||||
val osuUserAlias1 = USERS.`as`("osu_user_alias1")
|
||||
val osuUserAlias2 = USERS.`as`("osu_user_alias2")
|
||||
|
||||
val SKIPPED_SIMILARITY_MAPS = arrayOf(
|
||||
2635266, // - Death is just the beginning cs10
|
||||
2528044, // - Genkaku Catastrophe cs10
|
||||
2528067, // - Genkaku Catastrophe cs8
|
||||
2665747, // - expand cs9
|
||||
3063865, // - Kagayaku Hari no Kobitozoku ~ Little Princess 700 note stream
|
||||
4641077, // - granat cs10
|
||||
3616081, // - shop cs10
|
||||
3533781, //- uwa cs10
|
||||
3535358, // - uwa cs8
|
||||
3208341, // - mizuumi cs8
|
||||
4871320, // - youre a winner ninerik incident
|
||||
3237399, // - dialtone cs10
|
||||
4383507, // - another diff from the 700 note stream set
|
||||
1606883, // - tabi no tochu cs7
|
||||
4991602, // new bad map
|
||||
4997662, // new bad map
|
||||
1839017,
|
||||
4467121,
|
||||
4783487,
|
||||
3772967,
|
||||
3571032,
|
||||
4443518,
|
||||
5125702,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
fun getCharts(db: Record): List<ReplayDataChart> {
|
||||
@ -192,8 +165,7 @@ class ScoreService(
|
||||
SCORES.ERROR_KURTOSIS,
|
||||
SCORES.ERROR_SKEWNESS,
|
||||
SCORES.SLIDEREND_RELEASE_TIMES,
|
||||
SCORES.KEYPRESSES_TIMES,
|
||||
SCORES.LEADERBOARD_RANK,
|
||||
SCORES.KEYPRESSES_TIMES
|
||||
)
|
||||
.from(SCORES)
|
||||
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
|
||||
@ -256,8 +228,7 @@ class ScoreService(
|
||||
error_kurtosis = result.get(SCORES.ERROR_KURTOSIS, Double::class.java),
|
||||
error_skewness = result.get(SCORES.ERROR_SKEWNESS, Double::class.java),
|
||||
charts = charts,
|
||||
similar_scores = this.getSimilarScores(replayId),
|
||||
leaderboard_rank = result.get(SCORES.LEADERBOARD_RANK, Long::class.java)
|
||||
similar_scores = this.getSimilarScores(replayId)
|
||||
)
|
||||
this.loadComparableReplayData(replayData)
|
||||
return replayData
|
||||
@ -280,8 +251,7 @@ class ScoreService(
|
||||
BEATMAPS.STAR_RATING,
|
||||
SCORES.PP,
|
||||
SCORES.FRAMETIME,
|
||||
SCORES.UR,
|
||||
SCORES.LEADERBOARD_RANK,
|
||||
SCORES.UR
|
||||
)
|
||||
.from(SCORES)
|
||||
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
|
||||
@ -303,8 +273,7 @@ class ScoreService(
|
||||
beatmap_star_rating = it.get(BEATMAPS.STAR_RATING, Double::class.java),
|
||||
pp = it.get(SCORES.PP, Double::class.java),
|
||||
frametime = it.get(SCORES.FRAMETIME, Double::class.java),
|
||||
ur = it.get(SCORES.UR, Double::class.java),
|
||||
leaderboard_rank = it.get(SCORES.LEADERBOARD_RANK, Long::class.java),
|
||||
ur = it.get(SCORES.UR, Double::class.java)
|
||||
)
|
||||
|
||||
}
|
||||
@ -349,17 +318,13 @@ class ScoreService(
|
||||
osuScoreAlias1.REPLAY_ID,
|
||||
osuScoreAlias1.USER_ID,
|
||||
osuUserAlias1.USERNAME,
|
||||
osuUserAlias1.IS_BANNED,
|
||||
osuScoreAlias1.DATE,
|
||||
osuScoreAlias1.PP,
|
||||
osuScoreAlias1.LEADERBOARD_RANK,
|
||||
osuScoreAlias2.REPLAY_ID,
|
||||
osuScoreAlias2.USER_ID,
|
||||
osuUserAlias2.USERNAME,
|
||||
osuUserAlias2.IS_BANNED,
|
||||
osuScoreAlias2.DATE,
|
||||
osuScoreAlias2.PP,
|
||||
osuScoreAlias2.LEADERBOARD_RANK,
|
||||
BEATMAPS.BEATMAP_ID,
|
||||
BEATMAPS.TITLE,
|
||||
BEATMAPS.STAR_RATING,
|
||||
@ -379,10 +344,6 @@ class ScoreService(
|
||||
and(osuScoreAlias2.IS_BANNED.eq(false))
|
||||
}
|
||||
}
|
||||
// Globally skip maps that are known to have false positives (eg. high CS)
|
||||
.and(SCORES_SIMILARITY.BEATMAP_ID.notIn(*SKIPPED_SIMILARITY_MAPS))
|
||||
// Skip maps that have high CS values (smaller circles mean the replays will be naturally similar)
|
||||
.and(BEATMAPS.CS.lt(8.0))
|
||||
.and(condition)
|
||||
.orderBy(osuScoreAlias2.DATE.desc(), SCORES_SIMILARITY.SIMILARITY.asc())
|
||||
.fetch()
|
||||
@ -402,8 +363,6 @@ class ScoreService(
|
||||
fun getSimilarReplays(condition: Condition = DSL.noCondition()): List<SimilarReplayEntry> {
|
||||
val replays = getSimilarReplaysRecords(condition)
|
||||
return mapSimilarReplays(replays)
|
||||
// Filter scores where the imports have been out of order and the stolen replay's user has been banned
|
||||
.filter { !it.user_banned_2 }
|
||||
}
|
||||
|
||||
private fun mapSimilarReplays(replays: List<Record>) = replays.map {
|
||||
@ -414,9 +373,6 @@ class ScoreService(
|
||||
var userId1 = it.get(osuScoreAlias1.USER_ID, Long::class.java)
|
||||
var userId2 = it.get(osuScoreAlias2.USER_ID, Long::class.java)
|
||||
|
||||
var userBanned1 = it.get(osuUserAlias1.IS_BANNED, Boolean::class.java)
|
||||
var userBanned2 = it.get(osuUserAlias2.IS_BANNED, Boolean::class.java)
|
||||
|
||||
var username1 = it.get(osuUserAlias1.USERNAME, String::class.java)
|
||||
var username2 = it.get(osuUserAlias2.USERNAME, String::class.java)
|
||||
|
||||
@ -426,9 +382,6 @@ class ScoreService(
|
||||
var replayPp1 = it.get(osuScoreAlias1.PP, Double::class.java)
|
||||
var replayPp2 = it.get(osuScoreAlias2.PP, Double::class.java)
|
||||
|
||||
var replayLeaderboardRank1 = it.get(osuScoreAlias1.LEADERBOARD_RANK, Long::class.java)
|
||||
var replayLeaderboardRank2 = it.get(osuScoreAlias2.LEADERBOARD_RANK, Long::class.java)
|
||||
|
||||
// Swap logic if replayDate1 is after replayDate2
|
||||
if (replayDate1.isAfter(replayDate2)) {
|
||||
val tempReplayId = replayId1
|
||||
@ -439,10 +392,6 @@ class ScoreService(
|
||||
userId1 = userId2
|
||||
userId2 = tempUserId
|
||||
|
||||
val tempUserBanned = userBanned1
|
||||
userBanned1 = userBanned2
|
||||
userBanned2 = tempUserBanned
|
||||
|
||||
val tempUsername = username1
|
||||
username1 = username2
|
||||
username2 = tempUsername
|
||||
@ -454,10 +403,6 @@ class ScoreService(
|
||||
val tempReplayPp = replayPp1
|
||||
replayPp1 = replayPp2
|
||||
replayPp2 = tempReplayPp
|
||||
|
||||
val tempLeaderboardRank = replayLeaderboardRank1
|
||||
replayLeaderboardRank1 = replayLeaderboardRank2
|
||||
replayLeaderboardRank2 = tempLeaderboardRank
|
||||
}
|
||||
|
||||
SimilarReplayEntry(
|
||||
@ -465,8 +410,6 @@ class ScoreService(
|
||||
replay_id_2 = replayId2,
|
||||
user_id_1 = userId1,
|
||||
user_id_2 = userId2,
|
||||
user_banned_1 = userBanned1,
|
||||
user_banned_2 = userBanned2,
|
||||
username_1 = username1,
|
||||
username_2 = username2,
|
||||
beatmap_beatmapset_id = it.get(BEATMAPS.BEATMAPSET_ID, Long::class.java),
|
||||
@ -474,12 +417,10 @@ class ScoreService(
|
||||
replay_date_2 = Format.formatLocalDateTime(replayDate2),
|
||||
replay_pp_1 = replayPp1,
|
||||
replay_pp_2 = replayPp2,
|
||||
replay_leaderboard_rank_1 = replayLeaderboardRank1,
|
||||
replay_leaderboard_rank_2 = replayLeaderboardRank2,
|
||||
beatmap_id = it.get(BEATMAPS.BEATMAP_ID, Long::class.java),
|
||||
beatmap_title = it.get(BEATMAPS.TITLE, String::class.java),
|
||||
beatmap_star_rating = it.get(BEATMAPS.STAR_RATING, Double::class.java),
|
||||
similarity = it.get(SCORES_SIMILARITY.SIMILARITY, Double::class.java),
|
||||
similarity = it.get(SCORES_SIMILARITY.SIMILARITY, Double::class.java)
|
||||
)
|
||||
}.distinctBy {
|
||||
val (smallerId, largerId) = listOf(it.replay_id_1, it.replay_id_2).sorted()
|
||||
|
||||
@ -65,7 +65,7 @@ class UserScoreService(
|
||||
USER_SCORES.ERROR_SKEWNESS,
|
||||
USER_SCORES.SLIDEREND_RELEASE_TIMES,
|
||||
USER_SCORES.KEYPRESSES_TIMES,
|
||||
USER_SCORES.JUDGEMENTS,
|
||||
USER_SCORES.JUDGEMENTS
|
||||
)
|
||||
.from(USER_SCORES)
|
||||
.join(BEATMAPS).on(USER_SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
|
||||
@ -127,8 +127,7 @@ class UserScoreService(
|
||||
date = null,
|
||||
pp = null,
|
||||
rank = null,
|
||||
user_id = null,
|
||||
leaderboard_rank = null,
|
||||
user_id = null
|
||||
)
|
||||
this.scoreService.loadComparableReplayData(replayData)
|
||||
return replayData
|
||||
|
||||
@ -61,8 +61,7 @@ class CircleguardService {
|
||||
val sliderend_release_standard_deviation: Double?,
|
||||
val sliderend_release_standard_deviation_adjusted: Double?,
|
||||
|
||||
val judgements: List<Judgement>,
|
||||
val hit_count: Int?,
|
||||
val judgements: List<Judgement>
|
||||
)
|
||||
|
||||
fun postProcessReplay(replayResponse: ReplayResponse, mods: Int = 0) {
|
||||
|
||||
@ -18,7 +18,7 @@ import java.util.*
|
||||
@Service
|
||||
class MetabaseService : InitializingBean {
|
||||
|
||||
@Value("\${METABASE_API_KEY:nil}")
|
||||
@Value("\${METABASE_API_KEY}")
|
||||
private lateinit var metabaseApiKey: String
|
||||
|
||||
@Value("\${METABASE_URL:https://neko.nise.moe}")
|
||||
|
||||
@ -9,7 +9,6 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.InitializingBean
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.io.IOException
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.net.http.HttpClient
|
||||
@ -242,22 +241,6 @@ class OsuApi(
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserBeatmapScore(userId: Long, beatmapId: Int, scoreId: Long? = null): OsuApiModels.UserScore? {
|
||||
val response = doRequest("https://osu.ppy.sh/api/v2/beatmaps/$beatmapId/scores/users/$userId", emptyMap())
|
||||
|
||||
if (response == null) {
|
||||
this.logger.info("Error getting score on beatmap $beatmapId for user $userId")
|
||||
return null
|
||||
}
|
||||
|
||||
val score = when (response.statusCode()) {
|
||||
200 -> serializer.decodeFromString<OsuApiModels.UserScore>(response.body())
|
||||
else -> null
|
||||
}
|
||||
|
||||
return if (scoreId == null || score?.score?.id == scoreId) score else null
|
||||
}
|
||||
|
||||
fun searchBeatmapsets(cursor: OsuApiModels.BeatmapsetSearchResultCursor?): OsuApiModels.BeatmapsetSearchResult? {
|
||||
val queryParams = mutableMapOf(
|
||||
"s" to "ranked", // Status [only ranked]
|
||||
@ -363,12 +346,7 @@ class OsuApi(
|
||||
val waitTimes = listOf(15L, 30L, 60L)
|
||||
|
||||
for (waitTime in waitTimes) {
|
||||
val response = try {
|
||||
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
} catch (ex: IOException) {
|
||||
// Some transport level exception might be thrown, continue with the retry backoff and see if it fixes itself
|
||||
continue
|
||||
}
|
||||
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
|
||||
this.logger.debug("Request: {}", request.uri())
|
||||
this.logger.debug("Result: {}", response.statusCode())
|
||||
|
||||
@ -140,12 +140,6 @@ class OsuApiModels {
|
||||
val scores: List<Score>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserScore(
|
||||
val score: Score,
|
||||
val position: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class Grade {
|
||||
@SerialName("XH")
|
||||
|
||||
@ -1,283 +0,0 @@
|
||||
package com.nisemoe.nise.replays
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
|
||||
// JVM implementation of https://github.com/circleguard/wtc-lzma-compressor/tree/master
|
||||
|
||||
|
||||
|
||||
class WTC {
|
||||
companion object {
|
||||
private const val CURRENT_VERSION_HEADER: Short = 1
|
||||
|
||||
private val VERSION_HEADER_BYTE_ARRAY = byteArrayOf((CURRENT_VERSION_HEADER.toInt() and 0xFF).toByte(), ((CURRENT_VERSION_HEADER.toInt() shr 8) and 0xFF).toByte())
|
||||
|
||||
fun compress(stream: String): ByteArray {
|
||||
val lists = separate(stream)
|
||||
|
||||
val xs = unsortedDiffPackShortsToBytes(lists.x)
|
||||
val ys = unsortedDiffPackShortsToBytes(lists.y)
|
||||
|
||||
val ws = packIntsToBytes(lists.w)
|
||||
val zs = lists.z
|
||||
|
||||
fun packBytes(arr: ByteArray): ByteArray {
|
||||
val length = arr.size
|
||||
val buffer = ByteBuffer.allocate(4 + length).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
buffer.putInt(length)
|
||||
for (byte in arr) {
|
||||
buffer.put(byte)
|
||||
}
|
||||
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
val byteStream = ByteArrayOutputStream()
|
||||
|
||||
byteStream.writeBytes(VERSION_HEADER_BYTE_ARRAY)
|
||||
byteStream.writeBytes(packBytes(xs))
|
||||
byteStream.writeBytes(packBytes(ys))
|
||||
byteStream.writeBytes(packBytes(zs))
|
||||
byteStream.writeBytes(packBytes(ws))
|
||||
|
||||
return byteStream.toByteArray()
|
||||
}
|
||||
|
||||
fun decompress(data: ByteArray, hasVersionHeader: Boolean = true): String {
|
||||
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
fun unpackBytes(): ByteArray {
|
||||
val size = buffer.getInt()
|
||||
|
||||
val bytes = ByteArray(size)
|
||||
buffer.get(bytes, 0, size)
|
||||
|
||||
return bytes
|
||||
}
|
||||
|
||||
if (hasVersionHeader) {
|
||||
buffer.getShort() // Version - may be used in the future
|
||||
}
|
||||
|
||||
val xs = unpackBytes()
|
||||
val ys = unpackBytes()
|
||||
val zs = unpackBytes()
|
||||
val ws = unpackBytes()
|
||||
|
||||
val xxs = unsortedDiffUnpackBytesToShorts(xs)
|
||||
val yys = unsortedDiffUnpackBytesToShorts(ys)
|
||||
|
||||
val wws = unpackBytesToInts(ws)
|
||||
|
||||
return combine(FrameLists(
|
||||
x = xxs,
|
||||
y = yys,
|
||||
z = zs,
|
||||
w = wws,
|
||||
))
|
||||
}
|
||||
|
||||
data class FrameLists(
|
||||
val x: ShortArray,
|
||||
val y: ShortArray,
|
||||
val z: ByteArray,
|
||||
val w: IntArray,
|
||||
)
|
||||
|
||||
private fun unsortedDiffPackShortsToBytes(shorts: ShortArray): ByteArray {
|
||||
val start = shorts.first()
|
||||
val diff = arrayDiff(shorts)
|
||||
val packed = mutableListOf<Byte>()
|
||||
|
||||
fun pack(word: Short) {
|
||||
if (abs(word.toInt()) <= Byte.MAX_VALUE) {
|
||||
packed.add(word.toByte())
|
||||
}
|
||||
else {
|
||||
packed.add(Byte.MIN_VALUE)
|
||||
packed.add((word.toInt() and 0xFF).toByte())
|
||||
packed.add((word.toInt() shr 8).toByte())
|
||||
}
|
||||
}
|
||||
|
||||
pack(start)
|
||||
for (word in diff) {
|
||||
pack(word)
|
||||
}
|
||||
|
||||
return packed.toByteArray()
|
||||
}
|
||||
|
||||
private fun unsortedDiffUnpackBytesToShorts(int8s: ByteArray): ShortArray {
|
||||
val decoded = mutableListOf<Short>()
|
||||
|
||||
var i = 0
|
||||
while (i < int8s.size) {
|
||||
val byte = int8s[i]
|
||||
|
||||
if (byte == Byte.MIN_VALUE) {
|
||||
i++
|
||||
var word = int8s[i].toInt() and 0xFF
|
||||
i++
|
||||
word += int8s[i].toInt() shl 8
|
||||
decoded.add(word.toShort())
|
||||
}
|
||||
else {
|
||||
decoded.add(byte.toShort())
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
return cumSum(decoded.toShortArray())
|
||||
}
|
||||
|
||||
private fun packIntsToBytes(int32s: IntArray): ByteArray {
|
||||
val packed = mutableListOf<Byte>()
|
||||
|
||||
for (dw in int32s) {
|
||||
var dword = dw
|
||||
if (abs(dword) <= Byte.MAX_VALUE) {
|
||||
packed.add(dword.toByte())
|
||||
}
|
||||
else {
|
||||
packed.add(Byte.MIN_VALUE)
|
||||
packed.add((dword and 0xFF).toByte())
|
||||
dword = dword shr 8
|
||||
packed.add((dword and 0xFF).toByte())
|
||||
dword = dword shr 8
|
||||
packed.add((dword and 0xFF).toByte())
|
||||
dword = dword shr 8
|
||||
packed.add(dword.toByte())
|
||||
}
|
||||
}
|
||||
|
||||
return packed.toByteArray()
|
||||
}
|
||||
|
||||
private fun unpackBytesToInts(int8s: ByteArray): IntArray {
|
||||
val unpacked = mutableListOf<Int>()
|
||||
|
||||
var i = 0
|
||||
while (i < int8s.size) {
|
||||
val byte = int8s[i]
|
||||
|
||||
if (byte == Byte.MIN_VALUE) {
|
||||
i++
|
||||
var dword = int8s[i].toInt() and 0xFF
|
||||
i++
|
||||
dword += (int8s[i].toInt() shl 8) and 0xFF00
|
||||
i++
|
||||
dword += (int8s[i].toInt() shl 16) and 0xFF0000
|
||||
i++
|
||||
dword += int8s[i].toInt() shl 24
|
||||
unpacked.add(dword)
|
||||
}
|
||||
else {
|
||||
unpacked.add(byte.toInt())
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
return unpacked.toIntArray()
|
||||
}
|
||||
|
||||
private fun separate(stream: String): FrameLists {
|
||||
val frames = stream.split(',')
|
||||
val frameCount = frames.size
|
||||
|
||||
val ws = IntArray(frameCount)
|
||||
val xs = ShortArray(frameCount)
|
||||
val ys = ShortArray(frameCount)
|
||||
val zs = ByteArray(frameCount)
|
||||
|
||||
for ((i, frame) in frames.withIndex()) {
|
||||
if (frame.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
val splitFrame = frame.split('|')
|
||||
val w = splitFrame[0].toInt()
|
||||
val x = splitFrame[1].toFloat()
|
||||
val y = splitFrame[2].toFloat()
|
||||
val z = splitFrame[3].toInt()
|
||||
|
||||
val zz = z and 0xFF
|
||||
|
||||
var xx = round(x * 16).toInt()
|
||||
var yy = round(y * 16).toInt()
|
||||
|
||||
if (xx <= -0x8000) xx = -0x8000
|
||||
else if (xx >= 0x7FFF) xx = 0x7FFF
|
||||
|
||||
if (yy <= -0x8000) yy = -0x8000
|
||||
else if (yy >= 0x7FFF) yy = 0x7FFF
|
||||
|
||||
ws[i] = w
|
||||
xs[i] = xx.toShort()
|
||||
ys[i] = yy.toShort()
|
||||
zs[i] = zz.toByte()
|
||||
}
|
||||
|
||||
return FrameLists(
|
||||
x = xs,
|
||||
y = ys,
|
||||
z = zs,
|
||||
w = ws,
|
||||
)
|
||||
}
|
||||
|
||||
private fun combine(lists: FrameLists): String {
|
||||
val xArr = lists.x.map { it.toFloat() / 16 }
|
||||
|
||||
val yArr = lists.y.map { it.toFloat() / 16 }
|
||||
|
||||
val frames = arrayOfNulls<String>(xArr.size)
|
||||
|
||||
for (i in xArr.indices) {
|
||||
val x = xArr[i]
|
||||
val y = yArr[i]
|
||||
val z = lists.z[i]
|
||||
val w = lists.w[i]
|
||||
frames[i] = "$w|$x|$y|$z"
|
||||
}
|
||||
|
||||
return frames.joinToString(",")
|
||||
}
|
||||
|
||||
private fun arrayDiff(arr: ShortArray): ShortArray {
|
||||
if (arr.isEmpty()) {
|
||||
return emptyArray<Short>().toShortArray()
|
||||
}
|
||||
|
||||
val diffed = ShortArray(arr.size - 1)
|
||||
|
||||
for (index in 1..<arr.size) {
|
||||
diffed[index - 1] = (arr[index] - arr[index - 1]).toShort()
|
||||
}
|
||||
|
||||
return diffed
|
||||
}
|
||||
|
||||
private fun cumSum(arr: ShortArray): ShortArray {
|
||||
if (arr.isEmpty()) {
|
||||
return emptyArray<Short>().toShortArray()
|
||||
}
|
||||
|
||||
val cumArr = ShortArray(arr.size)
|
||||
|
||||
cumArr[0] = arr.first()
|
||||
for (index in 1..<arr.size) {
|
||||
cumArr[index] = (arr[index] + cumArr[index - 1]).toShort()
|
||||
}
|
||||
|
||||
return cumArr
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -28,9 +28,9 @@ class RssService(
|
||||
items.subList(0, items.size.coerceAtMost(50))
|
||||
|
||||
val channel = Channel(
|
||||
title = "nise.stedos.dev's feed and sneed",
|
||||
link = "https://nise.stedos.dev/rss",
|
||||
description = "Feed of *sus* scores for osu!std - /nise.stedos.dev/",
|
||||
title = "nise.moe's feed and sneed",
|
||||
link = "https://nise.moe/rss",
|
||||
description = "Feed of *sus* scores for osu!std - /nise.moe/",
|
||||
lastBuildDate = Date().toInstant().atZone(ZoneOffset.UTC).format(DateTimeFormatter.RFC_1123_DATE_TIME),
|
||||
item = items.map {
|
||||
Item(
|
||||
@ -42,7 +42,7 @@ class RssService(
|
||||
)
|
||||
|
||||
},
|
||||
atomLink = AtomLink(href = "https://nise.stedos.dev/rss.xml")
|
||||
atomLink = AtomLink(href = "https://nise.moe/rss.xml")
|
||||
)
|
||||
|
||||
return RssFeed(channel = channel)
|
||||
@ -74,8 +74,8 @@ class RssService(
|
||||
if(score1.addedAt != null) {
|
||||
val item = RssFeedController.IntermeriaryFeedItem(
|
||||
title = "Possible stolen replay",
|
||||
guid = "https://nise.stedos.dev/p/${score1.replayId}/${score2.replayId}",
|
||||
link = "https://nise.stedos.dev/p/${score1.replayId}/${score2.replayId}",
|
||||
guid = "https://nise.moe/p/${score1.replayId}/${score2.replayId}",
|
||||
link = "https://nise.moe/p/${score1.replayId}/${score2.replayId}",
|
||||
description = "Similarity: ${result[SCORES_SIMILARITY.SIMILARITY]}%\n" +
|
||||
"Replay by ${user1.username} on ${beatmap.artist} - ${beatmap.title} [${beatmap.version}] (${beatmap.starRating} stars)\n" +
|
||||
"Replay by ${user2.username} on ${beatmap.artist} - ${beatmap.title} [${beatmap.version}] (${beatmap.starRating} stars)",
|
||||
@ -113,8 +113,8 @@ class RssService(
|
||||
if(score.addedAt != null) {
|
||||
val item = RssFeedController.IntermeriaryFeedItem(
|
||||
title = "Suspicious score by ${user.username}",
|
||||
guid = "https://nise.stedos.dev/s/${score.replayId}",
|
||||
link = "https://nise.stedos.dev/s/${score.replayId}",
|
||||
guid = "https://nise.moe/s/${score.replayId}",
|
||||
link = "https://nise.moe/s/${score.replayId}",
|
||||
description = "New score by ${user.username} on ${beatmap.artist} - ${beatmap.title} [${beatmap.version}] (${beatmap.starRating} stars)",
|
||||
pubDate = score.addedAt!!
|
||||
)
|
||||
|
||||
@ -13,6 +13,7 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.util.StopWatch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Service
|
||||
class GlobalCache(
|
||||
@ -33,7 +34,7 @@ class GlobalCache(
|
||||
fun updateCaches() {
|
||||
val stopwatch = StopWatch()
|
||||
stopwatch.start()
|
||||
logger.info("Updating the cache...")
|
||||
logger.info("Updating the cache!")
|
||||
|
||||
runBlocking {
|
||||
val rssFeedDeferred = async { rssService.generateFeed() }
|
||||
@ -49,8 +50,6 @@ class GlobalCache(
|
||||
|
||||
stopwatch.stop()
|
||||
logger.info("Cache updated in {} seconds", String.format("%.2f", stopwatch.totalTimeSeconds))
|
||||
logger.info("[CACHE]: Similar replays count: {}", similarReplays?.size)
|
||||
logger.info("[CACHE]: Suspicious scores count: {}", suspiciousScores?.size)
|
||||
}
|
||||
|
||||
}
|
||||
@ -293,7 +293,7 @@ class ImportScores(
|
||||
dslContext.update(BEATMAPS)
|
||||
.set(BEATMAPS.BEATMAP_HASH, topScore.beatmap.checksum)
|
||||
.set(BEATMAPS.STAR_RATING, topScore.beatmap.difficulty_rating)
|
||||
.set(BEATMAPS.VERSION, topScore.beatmap.version)
|
||||
.set(BEATMAPS.VERSION, beatmap.version)
|
||||
.set(BEATMAPS.ARTIST, topScore.beatmapset!!.artist)
|
||||
.set(BEATMAPS.SOURCE, topScore.beatmapset.source)
|
||||
.set(BEATMAPS.TITLE, topScore.beatmapset.title)
|
||||
@ -727,8 +727,6 @@ class ImportScores(
|
||||
return
|
||||
}
|
||||
|
||||
val topScore = osuApi.getUserBeatmapScore(score.user_id, beatmapId, score.best_id)
|
||||
|
||||
dslContext.insertInto(SCORES)
|
||||
.set(SCORES.BEATMAP_ID, beatmapId)
|
||||
.set(SCORES.COUNT_300, score.statistics.count_300)
|
||||
@ -747,7 +745,6 @@ class ImportScores(
|
||||
.set(SCORES.REPLAY_ID, score.best_id)
|
||||
.set(SCORES.USER_ID, score.user_id)
|
||||
.set(SCORES.VERSION, CURRENT_VERSION)
|
||||
.set(SCORES.LEADERBOARD_RANK, topScore?.position)
|
||||
.execute()
|
||||
|
||||
this.statistics.scoresAddedToDatabase++
|
||||
@ -801,12 +798,6 @@ class ImportScores(
|
||||
return
|
||||
}
|
||||
|
||||
// If the score has a low amount of hits the UR calculation will be inaccurate, skip these plays
|
||||
if (processedReplay.hit_count == null || processedReplay.hit_count < 10) {
|
||||
this.logger.warn("Processed play has less than 10 hits, skipping score ${score.id}")
|
||||
return
|
||||
}
|
||||
|
||||
val compressedReplay = CompressReplay.compressReplay(scoreReplay.content.toByteArray())
|
||||
|
||||
val scoreId = dslContext.update(SCORES)
|
||||
|
||||
@ -78,7 +78,6 @@ class ScoreSearchController(
|
||||
val perfect: Boolean?,
|
||||
val pp: Double?,
|
||||
val rank: String?,
|
||||
val leaderboard_rank: Long?,
|
||||
val replay_id: Long?,
|
||||
val score: Long?,
|
||||
val ur: Double?,
|
||||
|
||||
@ -51,7 +51,6 @@ class ScoreSearchSchemaController(
|
||||
InternalSchemaField("perfect", "Perfect", Category.score, Type.boolean, false, "if score is a full combo", databaseField = SCORES.PERFECT),
|
||||
InternalSchemaField("pp", "Score PP", Category.score, Type.number, true, "performance points for score", databaseField = SCORES.PP),
|
||||
InternalSchemaField("rank", "Rank", Category.score, Type.grade, false, "score grade", databaseField = SCORES.RANK),
|
||||
InternalSchemaField("leaderboard_rank", "Leaderboard Rank", Category.score, Type.number, false, "leaderboard position of the play at import", databaseField = SCORES.LEADERBOARD_RANK),
|
||||
InternalSchemaField("replay_id", "Replay ID", Category.score, Type.number, false, "identifier for replay", databaseField = SCORES.REPLAY_ID),
|
||||
InternalSchemaField("score", "Score", Category.score, Type.number, false, "score value", databaseField = SCORES.SCORE),
|
||||
InternalSchemaField("ur", "UR", Category.metrics, Type.number, false, "unstable rate", databaseField = SCORES.UR),
|
||||
|
||||
@ -83,7 +83,6 @@ class ScoreSearchService(
|
||||
SCORES.ERROR_COEFFICIENT_OF_VARIATION,
|
||||
SCORES.ERROR_KURTOSIS,
|
||||
SCORES.ERROR_SKEWNESS,
|
||||
SCORES.LEADERBOARD_RANK,
|
||||
|
||||
// Beatmaps fields
|
||||
BEATMAPS.ARTIST,
|
||||
@ -187,7 +186,6 @@ class ScoreSearchService(
|
||||
perfect = it.get(SCORES.PERFECT),
|
||||
pp = it.get(SCORES.PP)?.roundToInt()?.toDouble(),
|
||||
rank = it.get(SCORES.RANK),
|
||||
leaderboard_rank = it.get(SCORES.LEADERBOARD_RANK),
|
||||
replay_id = it.get(SCORES.REPLAY_ID),
|
||||
score = it.get(SCORES.SCORE),
|
||||
ur = it.get(SCORES.UR),
|
||||
|
||||
@ -1 +0,0 @@
|
||||
ALTER TABLE public.scores ADD COLUMN leaderboard_rank BIGINT;
|
||||
@ -1,43 +0,0 @@
|
||||
package com.nisemoe.nise.osu
|
||||
|
||||
import com.nisemoe.nise.replays.WTC
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.testcontainers.shaded.org.bouncycastle.util.Arrays
|
||||
import java.nio.file.Paths
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class WtcTest {
|
||||
private val resourcesPath = Paths.get("src", "test", "resources", "wtc")
|
||||
|
||||
/**
|
||||
* Compares the bytes produced from the `wtcCompress()` function with bytes produced from the Python implementation of WTC.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["replay_1", "replay_2", "replay_3", "replay_4"])
|
||||
fun wtcCompressReturnsCorrectBytes(replayName: String) {
|
||||
val expected = resourcesPath.resolve("${replayName}_compressed.dat").toFile().readBytes()
|
||||
val replayEvents = resourcesPath.resolve("${replayName}_events.txt").toFile().readText()
|
||||
|
||||
val wtcCompressed = WTC.compress(replayEvents)
|
||||
|
||||
// We include a version header at the start of the compressed byte array - create a new array excluding these
|
||||
// so we can compare just the raw data with the Python WTC implementation's output.
|
||||
// (remove the first two bytes since the version header is a Short)
|
||||
val wtcCompressedWithoutVersionHeader = Arrays.copyOfRange(wtcCompressed, 2, wtcCompressed.size)
|
||||
|
||||
Assertions.assertArrayEquals(expected, wtcCompressedWithoutVersionHeader)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["replay_1", "replay_2", "replay_3", "replay_4"])
|
||||
fun wtcDecompressReturnsCorrectReplayFrames(replayName: String) {
|
||||
val expected = resourcesPath.resolve("${replayName}_decompressed.txt").toFile().readText()
|
||||
val compressedReplay = resourcesPath.resolve("${replayName}_compressed.dat").toFile().readBytes()
|
||||
|
||||
val wtcDecompressed = WTC.decompress(compressedReplay, false)
|
||||
|
||||
assertEquals(expected, wtcDecompressed)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -102,7 +102,6 @@ class ReplayResponse:
|
||||
sliderend_release_standard_deviation_adjusted: float
|
||||
|
||||
judgements: List[Hit]
|
||||
hit_count: int
|
||||
|
||||
def to_dict(self):
|
||||
d = asdict(self)
|
||||
@ -218,8 +217,7 @@ async def process_replay(request: Request):
|
||||
sliderend_release_standard_deviation=np.std(se, ddof=1),
|
||||
sliderend_release_standard_deviation_adjusted=np.std(my_filter_outliers(se), ddof=1),
|
||||
|
||||
judgements=judgements,
|
||||
hit_count=len(hits)
|
||||
judgements=judgements
|
||||
)
|
||||
return json(ur_response.to_dict())
|
||||
|
||||
|
||||
@ -34,5 +34,5 @@
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
<div class="text-center version">
|
||||
v20250622
|
||||
v20250213
|
||||
</div>
|
||||
|
||||
@ -97,10 +97,9 @@ export interface ReplayData {
|
||||
snaps: number;
|
||||
hits: number;
|
||||
|
||||
pp: number;
|
||||
pp: number,
|
||||
perfect: boolean;
|
||||
max_combo: number;
|
||||
leaderboard_rank?: number;
|
||||
max_combo: number,
|
||||
|
||||
mean_error?: number,
|
||||
error_variance?: number,
|
||||
@ -157,7 +156,6 @@ export interface SuspiciousScore {
|
||||
pp: number;
|
||||
frametime: number;
|
||||
ur: number;
|
||||
leaderboard_rank: number;
|
||||
}
|
||||
|
||||
export interface SimilarReplay {
|
||||
@ -170,8 +168,6 @@ export interface SimilarReplay {
|
||||
replay_date_2: string;
|
||||
replay_pp_1: number;
|
||||
replay_pp_2: number;
|
||||
replay_leaderboard_rank_1: number;
|
||||
replay_leaderboard_rank_2: number;
|
||||
beatmap_id: number;
|
||||
beatmap_title: string;
|
||||
beatmap_star_rating: number;
|
||||
|
||||
@ -129,7 +129,7 @@
|
||||
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
|
||||
<ng-container *ngIf="getValue(entry, column.name) !== null; else nullDisplay">
|
||||
<ng-container *ngIf="column.type == 'number'">
|
||||
{{ isFieldId(column.name) ? getValue(entry, column.name) : (getValue(entry, column.name) | number) }}
|
||||
{{ getValue(entry, column.name) | number }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="column.type == 'flag'">
|
||||
<span class="flag" [title]="getValue(entry, column.name)">{{ countryCodeToFlag(getValue(entry, column.name)) }}</span>
|
||||
|
||||
@ -352,8 +352,6 @@ export class SearchComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
isFieldId = (field: string): boolean => field === 'id' || field.includes("_id");
|
||||
|
||||
protected readonly countryCodeToFlag = countryCodeToFlag;
|
||||
protected readonly Math = Math;
|
||||
protected readonly formatDuration = formatDuration;
|
||||
|
||||
@ -32,7 +32,7 @@ export class TextReportService {
|
||||
report += `\n\n${this.getStealingReport(similarReplay)}\n`;
|
||||
}
|
||||
|
||||
report += `\n\nGenerated on ${site} - [${userDetails.username} profile](${environment.webUrl}/u/${userDetails.user_id})`;
|
||||
report += `\n\nGenerated on ${site} - [${userDetails.username} on ${site}](${environment.webUrl}/u/${userDetails.user_id})`;
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<ng-container *ngIf="this.isError">
|
||||
<div class="main term">
|
||||
<div class="text-center">
|
||||
An error occurred. Maybe try again in a bit?
|
||||
An error occured. Maybe try again in a bit?
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
@ -29,9 +29,9 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<a class="btn" [href]="'https://nise.stedos.dev/replay-viewer/' + this.pair.replays[0].replay_id + '/' + this.pair.replays[1].replay_id" target="_blank">Open in Replay Viewer</a>
|
||||
</div>
|
||||
<!-- <div class="text-center mt-2">
|
||||
<a class="btn" [href]="'https://replay.nise.moe/' + this.pair.replays[0].replay_id + '/' + this.pair.replays[1].replay_id" target="_blank">Open in Replay Viewer</a>
|
||||
</div> -->
|
||||
|
||||
<div class="some-page-wrapper text-center">
|
||||
<div class="row">
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<ng-container *ngIf="this.error">
|
||||
<div class="main term">
|
||||
<div class="text-center">
|
||||
An error occurred. Maybe try again in a bit?
|
||||
An error occured. Maybe try again in a bit?
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
@ -53,9 +53,9 @@
|
||||
Open in CircleGuard
|
||||
</a>
|
||||
|
||||
<a style="flex: 1" class="text-center" [href]="'https://nise.stedos.dev/replay-viewer/' + this.replayData.replay_id" target="_blank" [class.disabled]="!hasReplay()">
|
||||
<!-- <a style="flex: 1" class="text-center" [href]="'https://replay.nise.moe/' + this.replayData.replay_id" target="_blank" [class.disabled]="!hasReplay()">
|
||||
Open in Replay Viewer
|
||||
</a>
|
||||
</a> -->
|
||||
|
||||
</div>
|
||||
|
||||
@ -136,10 +136,6 @@
|
||||
<span class="stat-label">PP</span>
|
||||
<span class="stat-value">{{ this.replayData.pp | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="stat" *ngIf="this.replayData.leaderboard_rank">
|
||||
<span class="stat-label">Rank</span>
|
||||
<span class="stat-value">{{ this.replayData.leaderboard_rank }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-container">
|
||||
<div class="stat">
|
||||
|
||||
@ -51,20 +51,6 @@
|
||||
<input class="form-control" type="text" id="searchBeatmap" [(ngModel)]="this.filterManager.filters.searchBeatmap" (input)="filterScores()"
|
||||
[readOnly]="this.isUrlFilters" [disabled]="this.isUrlFilters">
|
||||
</p>
|
||||
|
||||
<!-- Min Rank -->
|
||||
<p>
|
||||
<label for="minRank" class="form-label">Min Rank (of stolen)</label>
|
||||
<input class="form-control" type="number" id="minRank" [(ngModel)]="this.filterManager.filters.minRank" (input)="filterScores()"
|
||||
[readOnly]="this.isUrlFilters" [disabled]="this.isUrlFilters">
|
||||
</p>
|
||||
|
||||
<!-- Max Rank -->
|
||||
<p>
|
||||
<label for="maxRank" class="form-label">Max Rank (of stolen)</label>
|
||||
<input class="form-control" type="number" id="maxRank" [(ngModel)]="this.filterManager.filters.maxRank" (input)="filterScores()"
|
||||
[readOnly]="this.isUrlFilters" [disabled]="this.isUrlFilters">
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div *ngIf="getTotalPages() > 1" style="padding: 20px">
|
||||
|
||||
@ -19,9 +19,6 @@ export interface FilterStolenReplays {
|
||||
|
||||
minSimilarity?: number;
|
||||
maxSimilarity?: number;
|
||||
|
||||
minRank?: number;
|
||||
maxRank?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@ -136,12 +133,7 @@ export class ViewSimilarReplaysComponent implements OnInit {
|
||||
const similarityMatch = (filters.minSimilarity !== undefined ? score.similarity >= filters.minSimilarity : true) &&
|
||||
(filters.maxSimilarity !== undefined ? score.similarity <= filters.maxSimilarity : true);
|
||||
|
||||
const scoreHasRank = score.replay_leaderboard_rank_2 > 0;
|
||||
|
||||
const rankMatch = (filters.minRank == null || (score.replay_leaderboard_rank_2 <= filters.minRank && scoreHasRank)) &&
|
||||
(filters.maxRank == null || score.replay_leaderboard_rank_2 >= filters.maxRank && scoreHasRank);
|
||||
|
||||
return usernameMatch && beatmapMatch && ppMatch && similarityMatch && rankMatch;
|
||||
return usernameMatch && beatmapMatch && ppMatch && similarityMatch;
|
||||
});
|
||||
|
||||
this.filterManager.persistToLocalStorage();
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
<input class="form-control" type="number" id="maxPP" [(ngModel)]="this.filterManager.filters.maxPP" (input)="filterScores()"
|
||||
[readOnly]="this.isUrlFilters" [disabled]="this.isUrlFilters">
|
||||
</p>
|
||||
|
||||
`
|
||||
<!-- Min cvUR -->
|
||||
<p>
|
||||
<label for="minUR" class="form-label">Min cvUR</label>
|
||||
@ -52,20 +52,6 @@
|
||||
<input class="form-control" type="text" id="searchBeatmap" [(ngModel)]="this.filterManager.filters.searchBeatmap" (input)="filterScores()"
|
||||
[readOnly]="this.isUrlFilters" [disabled]="this.isUrlFilters">
|
||||
</p>
|
||||
|
||||
<!-- Min Rank -->
|
||||
<p>
|
||||
<label for="minRank" class="form-label">Min Rank</label>
|
||||
<input class="form-control" type="number" id="minRank" [(ngModel)]="this.filterManager.filters.minRank" (input)="filterScores()"
|
||||
[readOnly]="this.isUrlFilters" [disabled]="this.isUrlFilters">
|
||||
</p>
|
||||
|
||||
<!-- Max Rank -->
|
||||
<p>
|
||||
<label for="maxRank" class="form-label">Max Rank</label>
|
||||
<input class="form-control" type="number" id="maxRank" [(ngModel)]="this.filterManager.filters.maxRank" (input)="filterScores()"
|
||||
[readOnly]="this.isUrlFilters" [disabled]="this.isUrlFilters">
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div *ngIf="getTotalPages() > 1" style="padding: 20px">
|
||||
|
||||
@ -19,9 +19,6 @@ export interface SuspiciousScoresFilter {
|
||||
|
||||
searchUsername?: string;
|
||||
searchBeatmap?: string;
|
||||
|
||||
minRank?: number;
|
||||
maxRank?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@ -155,12 +152,7 @@ export class ViewSuspiciousScoresComponent implements OnInit, OnDestroy {
|
||||
const urMatch = (filters.minUR == null || score.ur >= filters.minUR) &&
|
||||
(filters.maxUR == null || score.ur <= filters.maxUR);
|
||||
|
||||
const scoreHasRank = score.leaderboard_rank > 0;
|
||||
|
||||
const rankMatch = (filters.minRank == null || (score.leaderboard_rank <= filters.minRank && scoreHasRank)) &&
|
||||
(filters.maxRank == null || score.leaderboard_rank >= filters.maxRank && scoreHasRank);
|
||||
|
||||
return usernameMatch && beatmapMatch && ppMatch && urMatch && rankMatch;
|
||||
return usernameMatch && beatmapMatch && ppMatch && urMatch;
|
||||
});
|
||||
|
||||
// Presumably persists the current state of filters for future sessions
|
||||
|
||||
@ -298,6 +298,7 @@ fieldset button:not(:last-child) {
|
||||
|
||||
fieldset p label {
|
||||
display: block;
|
||||
margin-right: 100px !important;
|
||||
}
|
||||
|
||||
.badge.mod {
|
||||
|
||||
@ -18,24 +18,12 @@ export class DownloadFilesService {
|
||||
}
|
||||
|
||||
downloadCSV(input: Object[], columns: string[], fileName: string = 'data') {
|
||||
let csvData = columns.join(',') + '\n';
|
||||
const header = columns.join(',') + '\n';
|
||||
|
||||
for (const row of input) {
|
||||
let rowData: string[] = [];
|
||||
for (const column of columns) {
|
||||
let value = (row as Record<string, any>)[column];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = value.replaceAll(',', ';');
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.join(';');
|
||||
}
|
||||
|
||||
rowData.push(value);
|
||||
}
|
||||
|
||||
csvData += rowData.join(',') + '\n';
|
||||
}
|
||||
let csvData = input.map(row =>
|
||||
input.map(row => Object.values(row).join(',')).join('\n')
|
||||
).join('\n');
|
||||
csvData = header + csvData;
|
||||
|
||||
const blob = new Blob([csvData], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
@ -63,7 +63,6 @@ services:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
# redis
|
||||
REDIS_DB: 4
|
||||
REDIS_HOST: "redis"
|
||||
# Discord
|
||||
WEBHOOK_URL: ${WEBHOOK_URL}
|
||||
SCORES_WEBHOOK_URL: ${SCORES_WEBHOOK_URL}
|
||||
@ -101,10 +100,6 @@ services:
|
||||
container_name: nise-frontend
|
||||
restart: always
|
||||
|
||||
nise-replay-viewer:
|
||||
image: code.stedos.dev/stedos/nise-replay-viewer:latest
|
||||
container_name: nise-replay-viewer
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
postgres-data:
|
||||
@ -35,12 +35,6 @@ http {
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location /replay-viewer/ {
|
||||
proxy_pass http://nise-replay-viewer/;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM nginx:1.27.0-alpine
|
||||
FROM openresty/openresty:focal
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
|
||||
@ -4,17 +4,17 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>/replay/ - nise.stedos.dev</title>
|
||||
<link rel="icon" type="image/x-icon" href="https:/nise.stedos.dev/assets/favicon.ico">
|
||||
<title>/replay/ - nise.moe</title>
|
||||
<link rel="icon" type="image/x-icon" href="https://nise.moe/assets/favicon.ico">
|
||||
<!-- Embed data -->
|
||||
<meta property="og:title" content="/nise.stedos.dev/ - osu!cheaters finder">
|
||||
<meta property="og:title" content="/nise.moe/ - osu!cheaters finder">
|
||||
<meta property="og:description" content="crawls osu!std replays and tries to find naughty boys.">
|
||||
<meta property="og:url" content="https://nise.stedos.dev">
|
||||
<meta property="og:image" content="https://nise.stedos.dev/assets/banner.png">
|
||||
<meta property="og:url" content="https://nise.moe">
|
||||
<meta property="og:image" content="https://nise.moe/assets/banner.png">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="theme-color" content="#151515">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:image:src" content="https://nise.stedos.dev/assets/banner.png">
|
||||
<meta name="twitter:image:src" content="https://nise.moe/assets/banner.png">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -18,12 +18,9 @@ export function App() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// We might be beind a reverse proxy - remove the proxied URL
|
||||
const path = location.pathname.replace("/replay-viewer", "");
|
||||
|
||||
// This pattern matches one or more digits followed by an optional slash and any characters (non-greedy)
|
||||
const pathRegex = /^\/(\d+)(?:\/(\d+))?/;
|
||||
const match = path.match(pathRegex);
|
||||
const match = location.pathname.match(pathRegex);
|
||||
|
||||
if (match) {
|
||||
// match[1] will contain the first ID, match[2] (if present) will contain the second ID
|
||||
|
||||
@ -8,7 +8,7 @@ export function Navbar() {
|
||||
<Menubar className="rounded-none border-x-0 border-t-0 flex justify-between px-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={"https://nise.stedos.dev/assets/keisatsu-chan.png"} width={48}/>
|
||||
<img src={"https://nise.moe/assets/keisatsu-chan.png"} width={48}/>
|
||||
<h3 className="scroll-m-20 text-lg font-semibold tracking-tight">
|
||||
/replay/
|
||||
</h3>
|
||||
@ -24,14 +24,14 @@ export function Navbar() {
|
||||
{OsuRenderer.beatmap && (
|
||||
<>
|
||||
{OsuRenderer.replay2 == null && (
|
||||
<a href={"https://nise.stedos.dev/s/" + OsuRenderer.replay.info.id} target="_blank"
|
||||
<a href={"https://nise.moe/s/" + OsuRenderer.replay.info.id} target="_blank"
|
||||
className="flex items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent hover:bg-accent">
|
||||
View on nise.stedos.dev
|
||||
View on nise.moe
|
||||
</a>)}
|
||||
{OsuRenderer.replay2 && (
|
||||
<a href={"https://nise.stedos.dev/p/" + OsuRenderer.replay.info.id + "/" + OsuRenderer.replay2.info.id} target="_blank"
|
||||
<a href={"https://nise.moe/p/" + OsuRenderer.replay.info.id + "/" + OsuRenderer.replay2.info.id} target="_blank"
|
||||
className="flex items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent hover:bg-accent">
|
||||
View on nise.stedos.dev
|
||||
View on nise.moe
|
||||
</a>)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -39,7 +39,7 @@ export class Drawer {
|
||||
|
||||
static async loadDefaultImages() {
|
||||
const imageLoadPromises = Object.keys(Drawer.images).map(imageName =>
|
||||
loadImageAsync(`/replay-viewer/${imageName}.png`).then(
|
||||
loadImageAsync(`/${imageName}.png`).then(
|
||||
image => {
|
||||
Drawer.images[imageName as keyof typeof Drawer.images] = image;
|
||||
},
|
||||
|
||||
@ -231,7 +231,7 @@ export class OsuRenderer {
|
||||
static getApiUrl(): string {
|
||||
return document.location.hostname === "localhost"
|
||||
? `http://localhost:8080`
|
||||
: `https://nise.stedos.dev/api`;
|
||||
: `https://nise.moe/api`;
|
||||
}
|
||||
|
||||
static async loadReplayPairFromUrl(replayId1: number, replayId2: number) {
|
||||
|
||||
@ -9,5 +9,4 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
base: "/replay-viewer/",
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user