Compare commits

..

62 Commits

Author SHA1 Message Date
Stedoss
ef1031fcf3 Add more bad maps to skip 2025-07-10 02:43:43 +01:00
Stedoss
b25139367a Add info about reconstructing replays via model 2025-07-06 17:08:26 +01:00
Stedoss
b5bff780bb Fix typo 2025-07-06 16:34:00 +01:00
Stedoss
0ebf48dd29 Bump backend version 2025-07-02 22:50:29 +01:00
Stedoss
136cf5f15b Add some logs to show size of cached lists on refetch 2025-07-02 22:48:35 +01:00
Stedoss
6e0e2c7864 Add missing maps to excluded maps 2025-07-02 22:46:21 +01:00
Stedoss
ff30590fb5 Bump FE version 2025-06-22 16:56:25 +01:00
Stedoss
52bf7d56b0 Avoid using number formatting on ID fields
Pretty crude way to do it, but I guess it works?
2025-06-22 16:55:00 +01:00
Stedoss
86f307b53b Remove unused import 2025-06-22 16:29:28 +01:00
Stedoss
341cb269a4 Add explicit REDIS_HOST to docker-compose
This allows for default configuration to be changed on the builder's
host machine.
2025-06-21 19:59:21 +01:00
Stedoss
777230af9b Bump backend version 2025-06-21 19:39:08 +01:00
Stedoss
099c146f1b Don't use passed in beatmap version on score update
Not sure why the beatmap is even passed in here - but this seems
obviously incorrect, look at the properties around it.
2025-06-21 19:06:38 +01:00
Stedoss
cd6774e354 Use default value for METABASE_API_KEY
We probably want to remove this controller entirely, but this is ok for
dev environments for now
2025-06-21 18:27:44 +01:00
Stedoss
a028e26c98 Update some links in replay viewer 2025-05-14 19:09:27 +01:00
Stedoss
8cc50b244c Bump FE version 2025-05-14 19:03:35 +01:00
Stedoss
a2acb0cc52 Make changes for reverse proxy stuff 2025-05-14 19:03:27 +01:00
Stedoss
e91fa6dad2 Show link to replay viewer in scores again 2025-05-14 19:03:06 +01:00
Stedoss
0dc4f745a5 Add replay viewer to compose and proxy 2025-05-14 18:13:29 +01:00
Stedoss
427654764d Merge branch 'sansei' of github.com:circleguard/nise into sansei 2025-05-14 17:51:55 +01:00
Stedoss
4c7ea03cc9 Use standard nginx image for replay viewer 2025-05-14 17:50:24 +01:00
Stedoss
89f57c2ed9 Fix error spelling 2025-05-10 10:46:11 +01:00
Stedoss
8b1370bab3 Fix select includes 2025-05-09 19:11:24 +01:00
Stedoss
9d30103d55 Bump version 2025-05-09 18:43:57 +01:00
Stedoss
1faa80a1db Avoid showing banned user's stolen plays when score imports are out of order
Would prefer this to be handled in the importer, but it's enough of an
edge-case to just have it here for now.
2025-05-06 15:32:32 +09:00
Stedoss
b4824ce81f Turn endpoint to oneliner 2025-04-18 07:05:55 +09:00
Stedoss
0dd385728c Bump API version 2025-04-11 00:38:15 +01:00
Stedoss
7d1fe66b99 Fix banlist pagination offset being incorrect
Not sure what the point of the pagination variable is there, but we can
just use this as a constant for now.
2025-04-11 00:36:39 +01:00
Stedoss
bef1c72187 Bump version 2025-04-06 04:16:17 +01:00
Stedoss
2452e0a2be Skip plays with less than 10 hits from being analysed 2025-04-06 04:11:30 +01:00
Stedoss
5caa9ca14b Return hit_count from circleguard service 2025-04-06 04:01:35 +01:00
Stedoss
35a9e9a00e Bump version 2025-04-05 02:15:26 +01:00
Stedoss
573d642529 Exclude high CS replays from similar-replays endpoint 2025-04-05 02:15:09 +01:00
Stedoss
9e04397ac4 Add another cs9.5 map to compare ignore 2025-03-31 19:44:00 +01:00
Stedoss
6b5ec34821 Add more false positives to list 2025-03-25 18:17:00 +00:00
Stedoss
e177e46707 Bump backend version 2025-03-25 17:43:05 +00:00
Stedoss
e848c975c8 Skip certain maps that have high false positive rates in comparisons 2025-03-25 17:42:59 +00:00
Stedoss
c870c25afe Bump frontend version 2025-03-11 17:39:33 +00:00
Stedoss
db98f9e731 Woops 2025-03-11 17:38:36 +00:00
Stedoss
3a73942602 Add leaderboard_rank filters to similar-replays screen 2025-03-11 17:37:51 +00:00
Stedoss
f798a791bf Remove weird margin from fieldset 2025-03-11 17:32:21 +00:00
Stedoss
25e6b6b2dd Add min and max rank filters to suspicious-scores 2025-03-11 17:22:25 +00:00
Stedoss
73b002c7c7 Bump backend version 2025-03-11 16:46:11 +00:00
Stedoss
c6dd723ccd Return leaderboard_rank in similar-scores endpoint 2025-03-11 16:45:44 +00:00
Stedoss
c8ded05194 Return leaderboard_rank with suspicious scores 2025-03-11 16:32:51 +00:00
Stedoss
ad112e7b40 Use companion objects for static version and health 2025-03-10 22:41:24 +00:00
Stedoss
85ffbbf244 Bump versions 2025-03-10 22:13:16 +00:00
Stedoss
4f43edabc3 Add leaderboard_rank to score search 2025-03-10 22:10:30 +00:00
Stedoss
009633ea99 Show leaderboard rank on score view 2025-03-10 21:58:11 +00:00
Stedoss
aef795c010 Add leaderboard_score to score response 2025-03-10 21:57:21 +00:00
Stedoss
68ad9bb23a Add leaderboard_rank to scores table 2025-03-09 20:38:32 +00:00
Stedoss
c63d6ecc00 Bump version 2025-02-28 16:53:56 +00:00
Stedoss
1bc4326cff Add some comma filtering to CSV creation 2025-02-28 16:52:13 +00:00
Stedoss
ae4cad89e1 Fix CSV exports producing garbled data 2025-02-28 16:16:53 +00:00
Stedoss
d0ef99728e Pre-allocate arrays in seperate function 2025-02-23 21:54:50 +00:00
Stedoss
3ea6c2e8e4 Provide a better WTC interface 2025-02-23 20:46:58 +00:00
Stedoss
cc10fa221e Add tests for WTC compression 2025-02-23 20:40:51 +00:00
Stedoss
5e770a0f2c Add boolean to optionally decompress with version header
Mostly will be used for testing
2025-02-23 20:40:36 +00:00
Stedoss
5472d418f5 Correctly iterate through all elements for cumulative array 2025-02-23 20:37:41 +00:00
Stedoss
c788e9b336 Create wtc kotlin implementation 2025-02-23 12:01:01 +00:00
Stedoss
250227d60e Gracefully retry transport-level errors in osu http client 2025-02-20 01:31:55 +00:00
Stedoss
9206de7308 Change RSS feed links 2025-02-19 08:49:00 +00:00
Stedoss
306c05fcd7 Remove redundant site string in report 2025-02-17 08:38:03 +00:00
57 changed files with 647 additions and 76 deletions

View File

@ -26,8 +26,12 @@ 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)

View File

@ -8,7 +8,11 @@ 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
@ -317,6 +321,11 @@ 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)
@ -349,7 +358,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_REPLAY_ID, 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_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 getPrimaryKey(): UniqueKey<ScoresRecord> = SCORES_PKEY
override fun getUniqueKeys(): List<UniqueKey<ScoresRecord>> = listOf(REPLAY_ID_UNIQUE)

View File

@ -205,6 +205,10 @@ 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
// -------------------------------------------------------------------------
@ -214,7 +218,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): 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, leaderboardRank: Long? = null): this() {
this.id = id
this.beatmapId = beatmapId
this.count_100 = count_100
@ -261,6 +265,7 @@ open class ScoresRecord private constructor() : UpdatableRecordImpl<ScoresRecord
this.sliderendReleaseMedianAdjusted = sliderendReleaseMedianAdjusted
this.sliderendReleaseStandardDeviationAdjusted = sliderendReleaseStandardDeviationAdjusted
this.judgements = judgements
this.leaderboardRank = leaderboardRank
resetChangedOnNotNull()
}
}

View File

@ -56,7 +56,8 @@ data class SuspiciousScoreEntry(
val beatmap_star_rating: Double,
val pp: Double,
val frametime: Double,
val ur: Double
val ur: Double,
val leaderboard_rank: Long?,
)
data class SimilarReplayEntry(
@ -64,6 +65,8 @@ 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,
@ -71,6 +74,8 @@ 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,
@ -175,6 +180,7 @@ 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,
@ -198,6 +204,31 @@ 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,

View File

@ -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) * 10)
.offset((request.page - 1) * MAX_BANLIST_ENTRIES_PER_PAGE)
.fetch()
.map {
BanlistEntry(

View File

@ -8,15 +8,15 @@ data class HealthResponse(
val healthy: Boolean,
)
val healthResponse = HealthResponse(
healthy = true,
)
@RestController
class HealthController {
@GetMapping("/health")
fun healthCheck(): ResponseEntity<HealthResponse> {
return ResponseEntity.ok(healthResponse)
}
companion object {
val HEALTH = HealthResponse(
healthy = true,
)
}
@GetMapping("/health")
fun healthCheck(): ResponseEntity<HealthResponse> = ResponseEntity.ok(HEALTH)
}

View File

@ -8,12 +8,14 @@ data class VersionResponse(
val version: String,
)
val versionResponse = VersionResponse(
version = "v20250213",
)
@RestController
class VersionController {
@GetMapping("/version")
fun getVersion(): ResponseEntity<VersionResponse> = ResponseEntity.ok(versionResponse)
companion object {
val VERSION = VersionResponse(
version = "v20250702",
)
}
@GetMapping("/version")
fun getVersion(): ResponseEntity<VersionResponse> = ResponseEntity.ok(VERSION)
}

View File

@ -10,6 +10,7 @@ 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
@ -39,6 +40,32 @@ 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> {
@ -165,7 +192,8 @@ class ScoreService(
SCORES.ERROR_KURTOSIS,
SCORES.ERROR_SKEWNESS,
SCORES.SLIDEREND_RELEASE_TIMES,
SCORES.KEYPRESSES_TIMES
SCORES.KEYPRESSES_TIMES,
SCORES.LEADERBOARD_RANK,
)
.from(SCORES)
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
@ -228,7 +256,8 @@ 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)
similar_scores = this.getSimilarScores(replayId),
leaderboard_rank = result.get(SCORES.LEADERBOARD_RANK, Long::class.java)
)
this.loadComparableReplayData(replayData)
return replayData
@ -251,7 +280,8 @@ class ScoreService(
BEATMAPS.STAR_RATING,
SCORES.PP,
SCORES.FRAMETIME,
SCORES.UR
SCORES.UR,
SCORES.LEADERBOARD_RANK,
)
.from(SCORES)
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
@ -273,7 +303,8 @@ 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)
ur = it.get(SCORES.UR, Double::class.java),
leaderboard_rank = it.get(SCORES.LEADERBOARD_RANK, Long::class.java),
)
}
@ -318,13 +349,17 @@ 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,
@ -344,6 +379,10 @@ 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()
@ -363,6 +402,8 @@ 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 {
@ -373,6 +414,9 @@ 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)
@ -382,6 +426,9 @@ 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
@ -392,6 +439,10 @@ class ScoreService(
userId1 = userId2
userId2 = tempUserId
val tempUserBanned = userBanned1
userBanned1 = userBanned2
userBanned2 = tempUserBanned
val tempUsername = username1
username1 = username2
username2 = tempUsername
@ -403,6 +454,10 @@ class ScoreService(
val tempReplayPp = replayPp1
replayPp1 = replayPp2
replayPp2 = tempReplayPp
val tempLeaderboardRank = replayLeaderboardRank1
replayLeaderboardRank1 = replayLeaderboardRank2
replayLeaderboardRank2 = tempLeaderboardRank
}
SimilarReplayEntry(
@ -410,6 +465,8 @@ 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),
@ -417,10 +474,12 @@ 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()

View File

@ -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,7 +127,8 @@ class UserScoreService(
date = null,
pp = null,
rank = null,
user_id = null
user_id = null,
leaderboard_rank = null,
)
this.scoreService.loadComparableReplayData(replayData)
return replayData

View File

@ -61,7 +61,8 @@ class CircleguardService {
val sliderend_release_standard_deviation: Double?,
val sliderend_release_standard_deviation_adjusted: Double?,
val judgements: List<Judgement>
val judgements: List<Judgement>,
val hit_count: Int?,
)
fun postProcessReplay(replayResponse: ReplayResponse, mods: Int = 0) {

View File

@ -18,7 +18,7 @@ import java.util.*
@Service
class MetabaseService : InitializingBean {
@Value("\${METABASE_API_KEY}")
@Value("\${METABASE_API_KEY:nil}")
private lateinit var metabaseApiKey: String
@Value("\${METABASE_URL:https://neko.nise.moe}")

View File

@ -9,6 +9,7 @@ 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
@ -241,6 +242,22 @@ 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]
@ -346,7 +363,12 @@ class OsuApi(
val waitTimes = listOf(15L, 30L, 60L)
for (waitTime in waitTimes) {
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
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
}
this.logger.debug("Request: {}", request.uri())
this.logger.debug("Result: {}", response.statusCode())

View File

@ -140,6 +140,12 @@ class OsuApiModels {
val scores: List<Score>
)
@Serializable
data class UserScore(
val score: Score,
val position: Long,
)
@Serializable
enum class Grade {
@SerialName("XH")

View File

@ -0,0 +1,283 @@
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
}
}
}

View File

@ -28,9 +28,9 @@ class RssService(
items.subList(0, items.size.coerceAtMost(50))
val channel = Channel(
title = "nise.moe's feed and sneed",
link = "https://nise.moe/rss",
description = "Feed of *sus* scores for osu!std - /nise.moe/",
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/",
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.moe/rss.xml")
atomLink = AtomLink(href = "https://nise.stedos.dev/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.moe/p/${score1.replayId}/${score2.replayId}",
link = "https://nise.moe/p/${score1.replayId}/${score2.replayId}",
guid = "https://nise.stedos.dev/p/${score1.replayId}/${score2.replayId}",
link = "https://nise.stedos.dev/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.moe/s/${score.replayId}",
link = "https://nise.moe/s/${score.replayId}",
guid = "https://nise.stedos.dev/s/${score.replayId}",
link = "https://nise.stedos.dev/s/${score.replayId}",
description = "New score by ${user.username} on ${beatmap.artist} - ${beatmap.title} [${beatmap.version}] (${beatmap.starRating} stars)",
pubDate = score.addedAt!!
)

View File

@ -13,7 +13,6 @@ 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(
@ -34,7 +33,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() }
@ -50,6 +49,8 @@ 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)
}
}

View File

@ -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, beatmap.version)
.set(BEATMAPS.VERSION, topScore.beatmap.version)
.set(BEATMAPS.ARTIST, topScore.beatmapset!!.artist)
.set(BEATMAPS.SOURCE, topScore.beatmapset.source)
.set(BEATMAPS.TITLE, topScore.beatmapset.title)
@ -727,6 +727,8 @@ 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)
@ -745,6 +747,7 @@ 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++
@ -798,6 +801,12 @@ 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)

View File

@ -78,6 +78,7 @@ 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?,

View File

@ -51,6 +51,7 @@ 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),

View File

@ -83,6 +83,7 @@ class ScoreSearchService(
SCORES.ERROR_COEFFICIENT_OF_VARIATION,
SCORES.ERROR_KURTOSIS,
SCORES.ERROR_SKEWNESS,
SCORES.LEADERBOARD_RANK,
// Beatmaps fields
BEATMAPS.ARTIST,
@ -186,6 +187,7 @@ 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),

View File

@ -0,0 +1 @@
ALTER TABLE public.scores ADD COLUMN leaderboard_rank BIGINT;

View File

@ -0,0 +1,43 @@
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)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -102,6 +102,7 @@ class ReplayResponse:
sliderend_release_standard_deviation_adjusted: float
judgements: List[Hit]
hit_count: int
def to_dict(self):
d = asdict(self)
@ -217,7 +218,8 @@ 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
judgements=judgements,
hit_count=len(hits)
)
return json(ur_response.to_dict())

View File

@ -34,5 +34,5 @@
<router-outlet></router-outlet>
<div class="text-center version">
v20250213
v20250622
</div>

View File

@ -97,9 +97,10 @@ export interface ReplayData {
snaps: number;
hits: number;
pp: number,
pp: number;
perfect: boolean;
max_combo: number,
max_combo: number;
leaderboard_rank?: number;
mean_error?: number,
error_variance?: number,
@ -156,6 +157,7 @@ export interface SuspiciousScore {
pp: number;
frametime: number;
ur: number;
leaderboard_rank: number;
}
export interface SimilarReplay {
@ -168,6 +170,8 @@ 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;

View File

@ -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'">
{{ getValue(entry, column.name) | number }}
{{ isFieldId(column.name) ? getValue(entry, column.name) : (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>

View File

@ -352,6 +352,8 @@ 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;

View File

@ -32,7 +32,7 @@ export class TextReportService {
report += `\n\n${this.getStealingReport(similarReplay)}\n`;
}
report += `\n\nGenerated on ${site} - [${userDetails.username} on ${site}](${environment.webUrl}/u/${userDetails.user_id})`;
report += `\n\nGenerated on ${site} - [${userDetails.username} profile](${environment.webUrl}/u/${userDetails.user_id})`;
return report;
}

View File

@ -8,7 +8,7 @@
<ng-container *ngIf="this.isError">
<div class="main term">
<div class="text-center">
An error occured. Maybe try again in a bit?
An error occurred. 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://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="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="some-page-wrapper text-center">
<div class="row">

View File

@ -8,7 +8,7 @@
<ng-container *ngIf="this.error">
<div class="main term">
<div class="text-center">
An error occured. Maybe try again in a bit?
An error occurred. 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://replay.nise.moe/' + this.replayData.replay_id" target="_blank" [class.disabled]="!hasReplay()">
<a style="flex: 1" class="text-center" [href]="'https://nise.stedos.dev/replay-viewer/' + this.replayData.replay_id" target="_blank" [class.disabled]="!hasReplay()">
Open in Replay Viewer
</a> -->
</a>
</div>
@ -136,6 +136,10 @@
<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">

View File

@ -51,6 +51,20 @@
<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">

View File

@ -19,6 +19,9 @@ export interface FilterStolenReplays {
minSimilarity?: number;
maxSimilarity?: number;
minRank?: number;
maxRank?: number;
}
@Component({
@ -133,7 +136,12 @@ export class ViewSimilarReplaysComponent implements OnInit {
const similarityMatch = (filters.minSimilarity !== undefined ? score.similarity >= filters.minSimilarity : true) &&
(filters.maxSimilarity !== undefined ? score.similarity <= filters.maxSimilarity : true);
return usernameMatch && beatmapMatch && ppMatch && similarityMatch;
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;
});
this.filterManager.persistToLocalStorage();

View File

@ -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,6 +52,20 @@
<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">

View File

@ -19,6 +19,9 @@ export interface SuspiciousScoresFilter {
searchUsername?: string;
searchBeatmap?: string;
minRank?: number;
maxRank?: number;
}
@Component({
@ -152,7 +155,12 @@ export class ViewSuspiciousScoresComponent implements OnInit, OnDestroy {
const urMatch = (filters.minUR == null || score.ur >= filters.minUR) &&
(filters.maxUR == null || score.ur <= filters.maxUR);
return usernameMatch && beatmapMatch && ppMatch && urMatch;
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;
});
// Presumably persists the current state of filters for future sessions

View File

@ -298,7 +298,6 @@ fieldset button:not(:last-child) {
fieldset p label {
display: block;
margin-right: 100px !important;
}
.badge.mod {

View File

@ -18,12 +18,24 @@ export class DownloadFilesService {
}
downloadCSV(input: Object[], columns: string[], fileName: string = 'data') {
const header = columns.join(',') + '\n';
let csvData = columns.join(',') + '\n';
let csvData = input.map(row =>
input.map(row => Object.values(row).join(',')).join('\n')
).join('\n');
csvData = header + csvData;
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';
}
const blob = new Blob([csvData], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);

View File

@ -63,6 +63,7 @@ services:
POSTGRES_DB: ${DB_NAME}
# redis
REDIS_DB: 4
REDIS_HOST: "redis"
# Discord
WEBHOOK_URL: ${WEBHOOK_URL}
SCORES_WEBHOOK_URL: ${SCORES_WEBHOOK_URL}
@ -100,6 +101,10 @@ 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:

View File

@ -35,6 +35,12 @@ 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;
}
}
}

View File

@ -1,4 +1,4 @@
FROM openresty/openresty:focal
FROM nginx:1.27.0-alpine
RUN rm -rf /usr/share/nginx/html/*

View File

@ -4,17 +4,17 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>/replay/ - nise.moe</title>
<link rel="icon" type="image/x-icon" href="https://nise.moe/assets/favicon.ico">
<title>/replay/ - nise.stedos.dev</title>
<link rel="icon" type="image/x-icon" href="https:/nise.stedos.dev/assets/favicon.ico">
<!-- Embed data -->
<meta property="og:title" content="/nise.moe/ - osu!cheaters finder">
<meta property="og:title" content="/nise.stedos.dev/ - 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.moe">
<meta property="og:image" content="https://nise.moe/assets/banner.png">
<meta property="og:url" content="https://nise.stedos.dev">
<meta property="og:image" content="https://nise.stedos.dev/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.moe/assets/banner.png">
<meta name="twitter:image:src" content="https://nise.stedos.dev/assets/banner.png">
</head>
<body>

View File

@ -18,9 +18,12 @@ 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 = location.pathname.match(pathRegex);
const match = path.match(pathRegex);
if (match) {
// match[1] will contain the first ID, match[2] (if present) will contain the second ID

View File

@ -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.moe/assets/keisatsu-chan.png"} width={48}/>
<img src={"https://nise.stedos.dev/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.moe/s/" + OsuRenderer.replay.info.id} target="_blank"
<a href={"https://nise.stedos.dev/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.moe
View on nise.stedos.dev
</a>)}
{OsuRenderer.replay2 && (
<a href={"https://nise.moe/p/" + OsuRenderer.replay.info.id + "/" + OsuRenderer.replay2.info.id} target="_blank"
<a href={"https://nise.stedos.dev/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.moe
View on nise.stedos.dev
</a>)}
</>
)}

View File

@ -39,7 +39,7 @@ export class Drawer {
static async loadDefaultImages() {
const imageLoadPromises = Object.keys(Drawer.images).map(imageName =>
loadImageAsync(`/${imageName}.png`).then(
loadImageAsync(`/replay-viewer/${imageName}.png`).then(
image => {
Drawer.images[imageName as keyof typeof Drawer.images] = image;
},

View File

@ -231,7 +231,7 @@ export class OsuRenderer {
static getApiUrl(): string {
return document.location.hostname === "localhost"
? `http://localhost:8080`
: `https://nise.moe/api`;
: `https://nise.stedos.dev/api`;
}
static async loadReplayPairFromUrl(replayId1: number, replayId2: number) {

View File

@ -9,4 +9,5 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
base: "/replay-viewer/",
});