Compare commits
62 Commits
792b203255
...
ef1031fcf3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1031fcf3 | ||
|
|
b25139367a | ||
|
|
b5bff780bb | ||
|
|
0ebf48dd29 | ||
|
|
136cf5f15b | ||
|
|
6e0e2c7864 | ||
|
|
ff30590fb5 | ||
|
|
52bf7d56b0 | ||
|
|
86f307b53b | ||
|
|
341cb269a4 | ||
|
|
777230af9b | ||
|
|
099c146f1b | ||
|
|
cd6774e354 | ||
|
|
a028e26c98 | ||
|
|
8cc50b244c | ||
|
|
a2acb0cc52 | ||
|
|
e91fa6dad2 | ||
|
|
0dc4f745a5 | ||
|
|
427654764d | ||
|
|
4c7ea03cc9 | ||
|
|
89f57c2ed9 | ||
|
|
8b1370bab3 | ||
|
|
9d30103d55 | ||
|
|
1faa80a1db | ||
|
|
b4824ce81f | ||
|
|
0dd385728c | ||
|
|
7d1fe66b99 | ||
|
|
bef1c72187 | ||
|
|
2452e0a2be | ||
|
|
5caa9ca14b | ||
|
|
35a9e9a00e | ||
|
|
573d642529 | ||
|
|
9e04397ac4 | ||
|
|
6b5ec34821 | ||
|
|
e177e46707 | ||
|
|
e848c975c8 | ||
|
|
c870c25afe | ||
|
|
db98f9e731 | ||
|
|
3a73942602 | ||
|
|
f798a791bf | ||
|
|
25e6b6b2dd | ||
|
|
73b002c7c7 | ||
|
|
c6dd723ccd | ||
|
|
c8ded05194 | ||
|
|
ad112e7b40 | ||
|
|
85ffbbf244 | ||
|
|
4f43edabc3 | ||
|
|
009633ea99 | ||
|
|
aef795c010 | ||
|
|
68ad9bb23a | ||
|
|
c63d6ecc00 | ||
|
|
1bc4326cff | ||
|
|
ae4cad89e1 | ||
|
|
d0ef99728e | ||
|
|
3ea6c2e8e4 | ||
|
|
cc10fa221e | ||
|
|
5e770a0f2c | ||
|
|
5472d418f5 | ||
|
|
c788e9b336 | ||
|
|
250227d60e | ||
|
|
9206de7308 | ||
|
|
306c05fcd7 |
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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")
|
||||
|
||||
283
nise-backend/src/main/kotlin/com/nisemoe/nise/replays/Wtc.kt
Normal file
283
nise-backend/src/main/kotlin/com/nisemoe/nise/replays/Wtc.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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!!
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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?,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE public.scores ADD COLUMN leaderboard_rank BIGINT;
|
||||
43
nise-backend/src/test/kotlin/com/nisemoe/nise/osu/WtcTest.kt
Normal file
43
nise-backend/src/test/kotlin/com/nisemoe/nise/osu/WtcTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
BIN
nise-backend/src/test/resources/wtc/replay_1_compressed.dat
Normal file
BIN
nise-backend/src/test/resources/wtc/replay_1_compressed.dat
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
1
nise-backend/src/test/resources/wtc/replay_1_events.txt
Normal file
1
nise-backend/src/test/resources/wtc/replay_1_events.txt
Normal file
File diff suppressed because one or more lines are too long
BIN
nise-backend/src/test/resources/wtc/replay_2_compressed.dat
Normal file
BIN
nise-backend/src/test/resources/wtc/replay_2_compressed.dat
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
1
nise-backend/src/test/resources/wtc/replay_2_events.txt
Normal file
1
nise-backend/src/test/resources/wtc/replay_2_events.txt
Normal file
File diff suppressed because one or more lines are too long
BIN
nise-backend/src/test/resources/wtc/replay_3_compressed.dat
Normal file
BIN
nise-backend/src/test/resources/wtc/replay_3_compressed.dat
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
1
nise-backend/src/test/resources/wtc/replay_3_events.txt
Normal file
1
nise-backend/src/test/resources/wtc/replay_3_events.txt
Normal file
File diff suppressed because one or more lines are too long
BIN
nise-backend/src/test/resources/wtc/replay_4_compressed.dat
Normal file
BIN
nise-backend/src/test/resources/wtc/replay_4_compressed.dat
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
1
nise-backend/src/test/resources/wtc/replay_4_events.txt
Normal file
1
nise-backend/src/test/resources/wtc/replay_4_events.txt
Normal file
File diff suppressed because one or more lines are too long
@ -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())
|
||||
|
||||
|
||||
@ -34,5 +34,5 @@
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
<div class="text-center version">
|
||||
v20250213
|
||||
v20250622
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -298,7 +298,6 @@ fieldset button:not(:last-child) {
|
||||
|
||||
fieldset p label {
|
||||
display: block;
|
||||
margin-right: 100px !important;
|
||||
}
|
||||
|
||||
.badge.mod {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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:
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM openresty/openresty:focal
|
||||
FROM nginx:1.27.0-alpine
|
||||
|
||||
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.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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -9,4 +9,5 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
base: "/replay-viewer/",
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user