diff --git a/nise-backend/pom.xml b/nise-backend/pom.xml index 81e86b5..af79ca9 100644 --- a/nise-backend/pom.xml +++ b/nise-backend/pom.xml @@ -155,6 +155,18 @@ 1.9 + + + org.jetbrains.kotlinx + kandy-lets-plot + 0.6.0 + + + org.jetbrains.kotlinx + kotlin-statistics-jvm + 0.2.1 + + org.jetbrains.kotlin kotlin-test-junit5 @@ -269,4 +281,12 @@ + + + jetbrains + jetbrains + https://packages.jetbrains.team/maven/p/kds/kotlin-ds-maven + + + diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/UploadReplayController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/UploadReplayController.kt index f30eb97..9f8ee00 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/UploadReplayController.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/UploadReplayController.kt @@ -11,6 +11,7 @@ import com.nisemoe.nise.konata.Replay import com.nisemoe.nise.konata.compareSingleReplayWithSet import com.nisemoe.nise.osu.OsuApi import com.nisemoe.nise.scheduler.ImportScores +import com.nisemoe.nise.service.CompressReplay import org.jooq.DSLContext import org.nisemoe.mari.judgements.CompressJudgements import org.nisemoe.mari.replays.OsuReplay @@ -218,7 +219,10 @@ class UploadReplayController( val replaysForKonata = allReplays .filter { it.replayId != referenceReplay.id } - .map { Replay(string = it.replayData, id = it.replayId, mods = it.replayMods) } + .map { + val referenceReplayData = CompressReplay.decompressReplayToString(it.replayData) + Replay(string = referenceReplayData, id = it.replayId, mods = it.replayMods) + } .toTypedArray() val comparisonResult = compareSingleReplayWithSet(referenceReplay, replaysForKonata) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/UserDetailsController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/UserDetailsController.kt index 620fe98..39a9cbe 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/UserDetailsController.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/UserDetailsController.kt @@ -37,7 +37,7 @@ class UserDetailsController( @PostMapping("user-queue") fun addUserToQueue(@RequestBody request: UserQueueRequest): ResponseEntity { // Check if the user_id currently exists - this.userService.getUserById(userId = request.userId) + this.userService.getUserDetails(request.userId) ?: return ResponseEntity.notFound().build() val userQueueDetails = this.userQueueService.getUserQueueDetails(request.userId) @@ -53,12 +53,18 @@ class UserDetailsController( } data class UserDetailsRequest( - val userId: String + val userId: Long?, + val username: String? ) @PostMapping("user-details") fun getUserDetails(@RequestBody request: UserDetailsRequest): ResponseEntity { - val userDetailsExtended = this.userService.getUserDetails(username = request.userId) + // Check if BOTH are null or BOTH are not null + if((request.userId == null) == (request.username == null)) + return ResponseEntity.badRequest().build() + + val identifier: Any = request.userId ?: request.username ?: return ResponseEntity.badRequest().build() + val userDetailsExtended = this.userService.getUserDetails(identifier = identifier) ?: return ResponseEntity.notFound().build() val userId = userDetailsExtended.userDetails.user_id diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt index bbd2d3b..54b6a3c 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt @@ -82,7 +82,7 @@ class ScoreService( val replayData = result.get(SCORES.REPLAY, ByteArray::class.java) ?: return null - val replay = CompressReplay.decompressReplay(replayData) + val replay = CompressReplay.decompressReplayToString(replayData) var beatmapFile = result.get(BEATMAPS.BEATMAP_FILE, String::class.java) if(beatmapFile == null) { @@ -103,7 +103,7 @@ class ScoreService( return ReplayViewerData( beatmap = beatmapFile, - replay = String(replay, Charsets.UTF_8).trimEnd(','), + replay = replay, judgements = getJudgements(replayId), mods = mods ) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/UserService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/UserService.kt index 75c7ce6..823cc88 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/UserService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/UserService.kt @@ -42,40 +42,17 @@ class UserService( return dslContext.fetchCount(SCORES, SCORES.USER_ID.eq(userId)) } - fun getUserById(userId: Long): UserDetails? { - val user = dslContext.selectFrom(USERS) - .where(USERS.USER_ID.eq(userId)) - .fetchOneInto(UsersRecord::class.java) - - if (user != null) { - return UserDetails( - user.userId!!, - user.username!!, - user.rank, - user.ppRaw, - user.joinDate?.let { Format.formatLocalDateTime(it) }, - user.secondsPlayed, - user.country, - user.countryRank, - user.playcount - ) + fun getUserDetails(identifier: Any): UserDetailsExtended? { + val user = when (identifier) { + is Long -> dslContext.selectFrom(USERS) + .where(USERS.USER_ID.eq(identifier)) + .fetchOneInto(UsersRecord::class.java) + is String -> dslContext.selectFrom(USERS) + .where(USERS.USERNAME.equalIgnoreCase(identifier.lowercase())) + .fetchOneInto(UsersRecord::class.java) + else -> null } - // The database does NOT have the user; we will now use the osu!api - val apiUser = this.osuApi.getUserProfile(userId = userId.toString(), mode = "osu", key = "id") - ?: return null - - // Persist to database - insertApiUser(apiUser) - - return this.mapUserToDatabase(apiUser) - } - - fun getUserDetails(username: String): UserDetailsExtended? { - val user = dslContext.selectFrom(USERS) - .where(USERS.USERNAME.equalIgnoreCase(username.lowercase())) - .fetchOneInto(UsersRecord::class.java) - if (user != null) { val userDetails = UserDetails( user.userId!!, @@ -96,8 +73,11 @@ class UserService( } // The database does NOT have the user; we will now use the osu!api - val apiUser = this.osuApi.getUserProfile(userId = username, mode = "osu", key = "username") - ?: return null + val apiUser = when (identifier) { + is Long -> this.osuApi.getUserProfile(userId = identifier.toString(), mode = "osu", key = "id") + is String -> this.osuApi.getUserProfile(userId = identifier, mode = "osu", key = "username") + else -> null + } ?: return null // Persist to database insertApiUser(apiUser) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/Agent.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/Agent.kt index 9c44515..82e3b0b 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/Agent.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/Agent.kt @@ -5,6 +5,13 @@ import com.nisemoe.generated.tables.records.UsersRecord import com.nisemoe.generated.tables.references.SCORES import com.nisemoe.generated.tables.references.USERS import com.nisemoe.nise.osu.OsuApi +import org.jetbrains.kotlinx.kandy.dsl.plot +import org.jetbrains.kotlinx.kandy.ir.Plot +import org.jetbrains.kotlinx.kandy.letsplot.export.save +import org.jetbrains.kotlinx.kandy.letsplot.feature.layout +import org.jetbrains.kotlinx.kandy.letsplot.multiplot.model.PlotBunch +import org.jetbrains.kotlinx.kandy.util.color.Color +import org.jetbrains.kotlinx.statistics.kandy.layers.histogram import org.jooq.DSLContext import org.springframework.context.annotation.Profile import org.springframework.scheduling.annotation.Scheduled @@ -13,6 +20,7 @@ import java.time.LocalDateTime import java.time.temporal.ChronoUnit import kotlin.math.roundToInt + data class UserReport( val username: String, val susScore: Double, @@ -94,7 +102,9 @@ class Agent( SCORES.EDGE_HITS, SCORES.SNAPS, SCORES.KEYPRESSES_MEDIAN_ADJUSTED, - SCORES.ERROR_KURTOSIS + SCORES.ERROR_KURTOSIS, + SCORES.KEYPRESSES_TIMES, + SCORES.SLIDEREND_RELEASE_TIMES ) .from(SCORES) .where(SCORES.ADJUSTED_UR.lessOrEqual(256.0)) @@ -118,10 +128,44 @@ class Agent( urgencyScore = susScore.second ) reports.add(newReport) - println(newReport) } reports.sortByDescending { it.urgencyScore } + + println("Found ${reports.size} reports.") + + for(report in reports) { + + println(report) + + val plotBunch = mutableListOf() + + if(report.userScores.size <= 1) { + println("Not enough data to plot for ${report.username}") + continue + } + + for (score in report.userScores) { + if(score.KEYPRESSES_TIMES == null) continue + + plotBunch.add(plot { + histogram(score.KEYPRESSES_TIMES.toList()) { + fillColor = Color.BLACK + } + layout { + size = 600 to 200 + xAxisLabel = "x" + } + layout.title = "Kandy Getting Started Example" + }) + } + + val plotBunchs = plotBunch.mapIndexed { i, it -> + PlotBunch.Item(it, 0, 0 + (200 * i), 600, 200 * plotBunch.size) + } + + PlotBunch(plotBunchs).save(report.username + ".png") + } } fun mapScoreToMetrics(user: UsersRecord, score: ScoresRecord): ScoreMetrics { @@ -134,7 +178,9 @@ class Agent( edgeHits = score.edgeHits!!, snaps = score.snaps!!, keypressesMedianAdjusted = score.keypressesMedianAdjusted!!, - errorKurtosis = score.errorKurtosis!! + errorKurtosis = score.errorKurtosis!!, + SLIDEREND_RELEASE_TIMES = score.sliderendReleaseTimes?.filterNotNull()?.toTypedArray(), + KEYPRESSES_TIMES = score.keypressesTimes?.filterNotNull()?.toTypedArray() ) } @@ -147,7 +193,9 @@ class Agent( val edgeHits: Int, val snaps: Int, val keypressesMedianAdjusted: Double, - val errorKurtosis: Double + val errorKurtosis: Double, + val SLIDEREND_RELEASE_TIMES: Array?, + val KEYPRESSES_TIMES: Array? ) val ppWeight = 1.0 diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt index dc7f79f..38dcf68 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt @@ -23,9 +23,7 @@ import java.time.OffsetDateTime @Profile("fix:scores") @Service class FixOldScores( - private val dslContext: DSLContext, - private val osuApi: OsuApi, - private val circleguardService: CircleguardService + private val dslContext: DSLContext ){ companion object { @@ -138,85 +136,4 @@ class FixOldScores( .execute() } -// fun processScore(score: ScoresRecord) { -// -// // Fetch the beatmap file from database -// var beatmapFile = dslContext.select(BEATMAPS.BEATMAP_FILE) -// .from(BEATMAPS) -// .where(BEATMAPS.BEATMAP_ID.eq(score.beatmapId)) -// .fetchOneInto(String::class.java) -// -// if(beatmapFile == null) { -// this.logger.warn("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from database") -// -// beatmapFile = this.osuApi.getBeatmapFile(beatmapId = score.beatmapId!!) -// -// if(beatmapFile == null) { -// this.logger.error("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from osu!api") -// return -// } else { -// dslContext.update(BEATMAPS) -// .set(BEATMAPS.BEATMAP_FILE, beatmapFile) -// .where(BEATMAPS.BEATMAP_ID.eq(score.beatmapId)) -// .execute() -// } -// } -// -// val processedReplay: CircleguardService.ReplayResponse? = try { -// this.circleguardService.processReplay( -// replayData = score.replay!!.decodeToString(), beatmapData = beatmapFile, mods = score.mods ?: 0 -// ).get() -// } catch (e: Exception) { -// this.logger.error("Circleguard failed to process replay with score_id: ${score.id}") -// this.logger.error(e.stackTraceToString()) -// return -// } -// -// if (processedReplay == null || processedReplay.judgements.isEmpty()) { -// this.logger.error("Circleguard returned null and failed to process replay with score_id: ${score.id}") -// return -// } -// -// val scoreId = dslContext.update(SCORES) -// .set(SCORES.UR, processedReplay.ur) -// .set(SCORES.ADJUSTED_UR, processedReplay.adjusted_ur) -// .set(SCORES.FRAMETIME, processedReplay.frametime) -// .set(SCORES.SNAPS, processedReplay.snaps) -// .set(SCORES.MEAN_ERROR, processedReplay.mean_error) -// .set(SCORES.ERROR_VARIANCE, processedReplay.error_variance) -// .set(SCORES.ERROR_STANDARD_DEVIATION, processedReplay.error_standard_deviation) -// .set(SCORES.MINIMUM_ERROR, processedReplay.minimum_error) -// .set(SCORES.MAXIMUM_ERROR, processedReplay.maximum_error) -// .set(SCORES.ERROR_RANGE, processedReplay.error_range) -// .set(SCORES.ERROR_COEFFICIENT_OF_VARIATION, processedReplay.error_coefficient_of_variation) -// .set(SCORES.ERROR_KURTOSIS, processedReplay.error_kurtosis) -// .set(SCORES.ERROR_SKEWNESS, processedReplay.error_skewness) -// .set(SCORES.SNAPS, processedReplay.snaps) -// .set(SCORES.EDGE_HITS, processedReplay.edge_hits) -// .set(SCORES.KEYPRESSES_TIMES, processedReplay.keypresses_times?.toTypedArray()) -// .set(SCORES.KEYPRESSES_MEDIAN, processedReplay.keypresses_median) -// .set(SCORES.KEYPRESSES_MEDIAN_ADJUSTED, processedReplay.keypresses_median_adjusted) -// .set(SCORES.KEYPRESSES_STANDARD_DEVIATION, processedReplay.keypresses_standard_deviation) -// .set(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED, processedReplay.keypresses_standard_deviation_adjusted) -// .set(SCORES.SLIDEREND_RELEASE_TIMES, processedReplay.sliderend_release_times?.toTypedArray()) -// .set(SCORES.SLIDEREND_RELEASE_MEDIAN, processedReplay.sliderend_release_median) -// .set(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, processedReplay.sliderend_release_median_adjusted) -// .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION, processedReplay.sliderend_release_standard_deviation) -// .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, processedReplay.sliderend_release_standard_deviation_adjusted) -// .set(SCORES.JUDGEMENTS, CompressJudgements.compress(processedReplay.judgements)) -// .where(SCORES.REPLAY_ID.eq(score.replayId)) -// .returningResult(SCORES.ID) -// .fetchOne()?.getValue(SCORES.ID) -// -// if (scoreId == null) { -// this.logger.debug("Weird, failed to insert score into scores table. At least, it did not return an ID.") -// return -// } -// -// dslContext.update(SCORES) -// .set(SCORES.VERSION, CURRENT_VERSION) -// .where(SCORES.ID.eq(scoreId)) -// .execute() -// } - } \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt index 06af7f5..df2af9d 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt @@ -69,7 +69,7 @@ class ImportScores( companion object { - const val CURRENT_VERSION = 7 + const val CURRENT_VERSION = 8 const val SLEEP_AFTER_API_CALL = 500L const val UPDATE_USER_EVERY_DAYS = 7L const val UPDATE_BANNED_USERS_EVERY_DAYS = 3L @@ -536,7 +536,7 @@ class ImportScores( data class ReplayDto( val replayId: Long, val replayMods: Int, - val replayData: String + val replayData: ByteArray ) val sw = StopWatch() @@ -569,7 +569,8 @@ class ImportScores( val konataResults: List = try { val replaysForKonata = allReplays.map { - Replay(string = it.replayData, id = it.replayId, mods = it.replayMods) + val replayData = CompressReplay.decompressReplayToString(it.replayData) + Replay(string = replayData, id = it.replayId, mods = it.replayMods) }.toTypedArray() compareReplaySet(replaysForKonata) } catch (e: Exception) { diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/ChildQueriesDepthValidator.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ChildQueriesDepthValidator.kt similarity index 75% rename from nise-backend/src/main/kotlin/com/nisemoe/nise/search/ChildQueriesDepthValidator.kt rename to nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ChildQueriesDepthValidator.kt index 3dc4712..78c2eee 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/ChildQueriesDepthValidator.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ChildQueriesDepthValidator.kt @@ -1,4 +1,4 @@ -package com.nisemoe.nise.search +package com.nisemoe.nise.search.score import jakarta.validation.Constraint import jakarta.validation.ConstraintValidator @@ -15,17 +15,17 @@ annotation class ValidChildQueriesDepth( val payload: Array> = [] ) -class ChildQueriesDepthValidator : ConstraintValidator> { +class ChildQueriesDepthValidator : ConstraintValidator> { override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) { super.initialize(constraintAnnotation) } - override fun isValid(queries: List?, context: ConstraintValidatorContext): Boolean { + override fun isValid(queries: List?, context: ConstraintValidatorContext): Boolean { return queries?.all { validateChildQueriesDepth(it, 1) } ?: true } - private fun validateChildQueriesDepth(query: SearchController.SearchQuery, currentDepth: Int): Boolean { + private fun validateChildQueriesDepth(query: ScoreSearchController.SearchQuery, currentDepth: Int): Boolean { if (currentDepth > 10) return false query.childQueries?.forEach { if (!validateChildQueriesDepth(it, currentDepth + 1)) return false diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchController.kt similarity index 94% rename from nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchController.kt rename to nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchController.kt index 66b9446..edf9685 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchController.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchController.kt @@ -1,4 +1,4 @@ -package com.nisemoe.nise.search +package com.nisemoe.nise.search.score import com.fasterxml.jackson.annotation.JsonInclude import jakarta.validation.Valid @@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RestController @RestController -class SearchController( - private val searchService: SearchService +class ScoreSearchController( + private val scoreSearchService: ScoreSearchService ) { private val logger = LoggerFactory.getLogger(javaClass) @@ -28,7 +28,7 @@ class SearchController( @JsonInclude(JsonInclude.Include.NON_NULL) data class SearchResponse( - val scores: List, + val results: List, val pagination: SearchResponsePagination ) @@ -61,7 +61,8 @@ class SearchController( val user_count_300: Long?, val user_count_100: Long?, val user_count_50: Long?, - val user_count_miss: Int?, + val user_count_miss: Long?, + val user_is_banned: Boolean?, // Score fields val id: Int?, @@ -150,7 +151,7 @@ class SearchController( val stopwatch = StopWatch() stopwatch.start() try { - val response = this.searchService.search(request) + val response = this.scoreSearchService.search(request) return ResponseEntity.ok(response) } catch (e: Exception) { this.logger.error("Error while searching: {}", e.stackTraceToString()) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchSchemaController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchSchemaController.kt similarity index 98% rename from nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchSchemaController.kt rename to nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchSchemaController.kt index 231079f..5a217f7 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchSchemaController.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchSchemaController.kt @@ -1,4 +1,4 @@ -package com.nisemoe.nise.search +package com.nisemoe.nise.search.score import com.nisemoe.generated.tables.references.BEATMAPS import com.nisemoe.generated.tables.references.SCORES @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController @RestController -class SearchSchemaController( +class ScoreSearchSchemaController( private val authService: AuthService, ) { @@ -34,6 +34,7 @@ class SearchSchemaController( InternalSchemaField("user_count_100", "100s", Category.user, Type.number, false, "number of 100 hits", databaseField = USERS.COUNT_100), InternalSchemaField("user_count_50", "50s", Category.user, Type.number, false, "number of 50 hits", databaseField = USERS.COUNT_50), InternalSchemaField("user_count_miss", "Misses", Category.user, Type.number, false, "missed hits", databaseField = USERS.COUNT_MISS), + InternalSchemaField("user_is_banned", "Is Banned", Category.user, Type.boolean, false, "is the user banned?", databaseField = USERS.IS_BANNED), // Score fields InternalSchemaField("is_banned", "Banned", Category.score, Type.boolean, false, "has to score been deleted?", databaseField = SCORES.IS_BANNED), diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchService.kt similarity index 91% rename from nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchService.kt rename to nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchService.kt index c8d4a9b..64ee443 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/score/ScoreSearchService.kt @@ -1,4 +1,4 @@ -package com.nisemoe.nise.search +package com.nisemoe.nise.search.score import com.nisemoe.generated.tables.references.BEATMAPS import com.nisemoe.generated.tables.references.SCORES @@ -11,12 +11,12 @@ import org.springframework.stereotype.Service import kotlin.math.roundToInt @Service -class SearchService( +class ScoreSearchService( private val dslContext: DSLContext, private val authService: AuthService ) { - fun search(request: SearchController.SearchRequest): SearchController.SearchResponse { + fun search(request: ScoreSearchController.SearchRequest): ScoreSearchController.SearchResponse { var baseQuery = DSL.noCondition() for (query in request.queries.filter { it.predicates.isNotEmpty() }) { val condition = buildCondition(query) @@ -44,6 +44,7 @@ class SearchService( USERS.COUNT_300, USERS.COUNT_100, USERS.COUNT_50, + USERS.COUNT_MISS, // Scores fields SCORES.ID, @@ -107,8 +108,8 @@ class SearchService( if (request.sorting.field.isNotBlank()) orderBy(buildSorting(request.sorting)) } - .offset((request.page - 1) * SearchController.RESULTS_PER_PAGE) - .limit(SearchController.RESULTS_PER_PAGE) + .offset((request.page - 1) * ScoreSearchController.RESULTS_PER_PAGE) + .limit(ScoreSearchController.RESULTS_PER_PAGE) val results = query .fetch() @@ -121,19 +122,19 @@ class SearchService( .where(baseQuery) .fetchOne(0, Int::class.java) ?: 0 - return SearchController.SearchResponse( - scores = mapRecordToScores(results), - pagination = SearchController.SearchResponsePagination( + return ScoreSearchController.SearchResponse( + results = mapRecordToScores(results), + pagination = ScoreSearchController.SearchResponsePagination( currentPage = request.page, - pageSize = SearchController.RESULTS_PER_PAGE, + pageSize = ScoreSearchController.RESULTS_PER_PAGE, totalResults = totalResults ) ) } - private fun mapRecordToScores(results: Result): MutableList = + private fun mapRecordToScores(results: Result): MutableList = results.map { - SearchController.SearchResponseEntry( + ScoreSearchController.SearchResponseEntry( // User fields user_id = it.get(SCORES.USER_ID), user_username = it.get(USERS.USERNAME), @@ -150,7 +151,8 @@ class SearchService( user_count_300 = it.get(USERS.COUNT_300), user_count_100 = it.get(USERS.COUNT_100), user_count_50 = it.get(USERS.COUNT_50), - user_count_miss = it.get(SCORES.COUNT_MISS), + user_count_miss = it.get(USERS.COUNT_MISS), + user_is_banned = it.get(USERS.IS_BANNED), // Score fields id = it.get(SCORES.ID), @@ -199,7 +201,7 @@ class SearchService( ) } - fun buildCondition(query: SearchController.SearchQuery): Condition { + fun buildCondition(query: ScoreSearchController.SearchQuery): Condition { // Handle base predicates var baseCondition = buildPredicateCondition(query.predicates.first()) query.predicates.drop(1).forEach { predicate -> @@ -226,7 +228,7 @@ class SearchService( return baseCondition } - private fun buildSorting(sorting: SearchController.SearchSorting): OrderField<*> { + private fun buildSorting(sorting: ScoreSearchController.SearchSorting): OrderField<*> { val field = mapPredicateFieldToDatabaseField(sorting.field) return when (sorting.order.lowercase()) { "asc" -> field.asc() @@ -235,7 +237,7 @@ class SearchService( } } - private fun buildPredicateCondition(predicate: SearchController.SearchPredicate): Condition { + private fun buildPredicateCondition(predicate: ScoreSearchController.SearchPredicate): Condition { val field = mapPredicateFieldToDatabaseField(predicate.field.name) return when (predicate.field.type.lowercase()) { "number" -> buildNumberCondition(field as Field, predicate.operator.operatorType, predicate.value.toDouble()) @@ -250,7 +252,7 @@ class SearchService( } private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> { - val databaseField = SearchSchemaController.internalFields.first { + val databaseField = ScoreSearchSchemaController.internalFields.first { it.name == predicateName && it.databaseField != null } return databaseField.databaseField!! diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserChildQueriesDepthValidator.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserChildQueriesDepthValidator.kt new file mode 100644 index 0000000..bdc010b --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserChildQueriesDepthValidator.kt @@ -0,0 +1,35 @@ +package com.nisemoe.nise.search.user + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import kotlin.reflect.KClass + +@MustBeDocumented +@Constraint(validatedBy = [UserChildQueriesDepthValidator::class]) +@Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FIELD]) +@Retention(AnnotationRetention.RUNTIME) +annotation class ValidChildQueriesDepth( + val message: String = "Exceeds maximum depth of child queries", + val groups: Array> = [], + val payload: Array> = [] +) + +class UserChildQueriesDepthValidator : ConstraintValidator> { + + override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) { + super.initialize(constraintAnnotation) + } + + override fun isValid(queries: List?, context: ConstraintValidatorContext): Boolean { + return queries?.all { validateChildQueriesDepth(it, 1) } ?: true + } + + private fun validateChildQueriesDepth(query: UserSearchController.SearchQuery, currentDepth: Int): Boolean { + if (currentDepth > 10) return false + query.childQueries?.forEach { + if (!validateChildQueriesDepth(it, currentDepth + 1)) return false + } + return true + } +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchController.kt new file mode 100644 index 0000000..83ab3a1 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchController.kt @@ -0,0 +1,121 @@ +package com.nisemoe.nise.search.user + +import com.fasterxml.jackson.annotation.JsonInclude +import jakarta.validation.Valid +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.util.StopWatch +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserSearchController( + private val userSearchService: UserSearchService +) { + + private val logger = LoggerFactory.getLogger(javaClass) + + companion object { + + const val RESULTS_PER_PAGE = 50 + + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + data class SearchResponse( + val results: List, + val pagination: SearchResponsePagination + ) + + data class SearchResponsePagination( + val currentPage: Int, + val pageSize: Int, + val totalResults: Int + ) { + + val totalPages: Int + get() = if (totalResults % pageSize == 0) totalResults / pageSize else totalResults / pageSize + 1 + + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + data class SearchResponseEntry( + val user_id: Long?, + val username: String?, + val join_date: String?, + val country: String?, + val country_rank: Long?, + val rank: Long?, + val pp_raw: Double?, + val accuracy: Double?, + val playcount: Long?, + val total_score: Long?, + val ranked_score: Long?, + val seconds_played: Long?, + val count_300: Long?, + val count_100: Long?, + val count_50: Long?, + val count_miss: Long?, + val is_banned: Boolean? + ) + + data class SearchRequest( + @Valid @field:ValidChildQueriesDepth @field:Size(max = 10) val queries: List, + @Valid val sorting: SearchSorting, + @field:Min(1) val page: Int + ) + + data class SearchSorting( + @field:NotBlank @field:Size(max = 300) val field: String, + @field:NotBlank @field:Size(max = 300) val order: String + ) + + data class SearchQuery( + @field:NotBlank @field:Size(max = 300) val logicalOperator: String, + @Valid @field:Size(max = 10) val predicates: List, + @Valid @field:ValidChildQueriesDepth @field:Size(max = 10) val childQueries: List? + ) + + data class SearchPredicate( + @Valid val field: SearchField, + @Valid val operator: SearchPredicateOperator, + @field:NotBlank @field:Size(max = 300) val value: String + ) + + data class SearchPredicateOperator( + @field:NotBlank @field:Size(max = 300) val operatorType: String, + @field:NotBlank @field:Size(max = 300) val acceptsValues: String + ) + + data class SearchField( + @field:NotBlank @field:Size(max = 300) val name: String, + @field:NotBlank @field:Size(max = 300) val type: String + ) + + @PostMapping("search-user") + fun doSearch(@RequestBody @Valid request: SearchRequest, @RequestHeader("X-NISE-API") apiVersion: String): ResponseEntity { + if (apiVersion.isBlank()) + return ResponseEntity.badRequest().build() + + // TODO: CSRF + + val stopwatch = StopWatch() + stopwatch.start() + try { + val response = this.userSearchService.search(request) + return ResponseEntity.ok(response) + } catch (e: Exception) { + this.logger.error("Error while searching: {}", e.stackTraceToString()) + return ResponseEntity.status(500).build() + } finally { + stopwatch.stop() + this.logger.info("Search took {} seconds", String.format("%.2f", stopwatch.totalTimeSeconds)) + } + } + +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchSchemaController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchSchemaController.kt new file mode 100644 index 0000000..f4708b7 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchSchemaController.kt @@ -0,0 +1,96 @@ +package com.nisemoe.nise.search.user + +import com.nisemoe.generated.tables.references.USERS +import com.nisemoe.nise.service.AuthService +import org.jooq.Field +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserSearchSchemaController( + private val authService: AuthService, +) { + + companion object { + + val internalFields = listOf( + // User fields + InternalSchemaField("user_id", "ID", Category.user, Type.number, false, "unique identifier for a user", databaseField = USERS.USER_ID), + InternalSchemaField("username", "Username", Category.user, Type.string, true, "user's name", databaseField = USERS.USERNAME), + InternalSchemaField("join_date", "Join Date", Category.user, Type.datetime, false, "when the user joined", databaseField = USERS.JOIN_DATE), + InternalSchemaField("country", "Country", Category.user, Type.flag, true, "user's country flag", databaseField = USERS.COUNTRY), + InternalSchemaField("country_rank", "Country Rank", Category.user, Type.number, false, "ranking within user's country", databaseField = USERS.COUNTRY_RANK), + InternalSchemaField("rank", "Rank", Category.user, Type.number, false, "global ranking", databaseField = USERS.RANK), + InternalSchemaField("pp_raw", "User PP", Category.user, Type.number, true, "performance points", databaseField = USERS.PP_RAW), + InternalSchemaField("accuracy", "User Accuracy", Category.user, Type.number, false, "hit accuracy percentage", databaseField = USERS.ACCURACY), + InternalSchemaField("playcount", "Playcount", Category.user, Type.number, false, "total plays", databaseField = USERS.PLAYCOUNT), + InternalSchemaField("total_score", "Total Score", Category.user, Type.number, false, "cumulative score", databaseField = USERS.TOTAL_SCORE), + InternalSchemaField("ranked_score", "Ranked Score", Category.user, Type.number, false, "score from ranked maps", databaseField = USERS.RANKED_SCORE), + InternalSchemaField("seconds_played", "Play Time", Category.user, Type.playtime, true, "total play time in seconds", databaseField = USERS.SECONDS_PLAYED), + InternalSchemaField("count_300", "300s", Category.user, Type.number, false, "number of 300 hits", databaseField = USERS.COUNT_300), + InternalSchemaField("count_100", "100s", Category.user, Type.number, false, "number of 100 hits", databaseField = USERS.COUNT_100), + InternalSchemaField("count_50", "50s", Category.user, Type.number, false, "number of 50 hits", databaseField = USERS.COUNT_50), + InternalSchemaField("count_miss", "Misses", Category.user, Type.number, false, "missed hits", databaseField = USERS.COUNT_MISS), + InternalSchemaField("is_banned", "Is Banned", Category.user, Type.boolean, false, "is the user banned?", databaseField = USERS.IS_BANNED), + ) + + } + + data class InternalSchemaField( + val name: String, + val shortName: String, + val category: Category, + val type: Type, + val active: Boolean, + val description: String, + + val isPrivileged: Boolean = false, + val databaseField: Field<*>? = null + ) + + data class SchemaField( + val name: String, + val shortName: String, + val category: Category, + val type: Type, + val active: Boolean, + val description: String + ) + + enum class Category { + user, score, beatmap + } + + enum class Type { + number, string, flag, grade, boolean, datetime, playtime + } + + data class SearchSchema( + val fields: List + ) + + @GetMapping("search-user/schema") + fun getSearchSchema(): ResponseEntity { + // Map to SchemaField + val isUserAdmin = authService.isAdmin() + val fields = internalFields + .filter { + return@filter !(it.isPrivileged && !isUserAdmin) + } + .map { + SchemaField( + name = it.name, + shortName = it.shortName, + category = it.category, + type = it.type, + active = it.active, + description = it.description + ) + } + + val schema = SearchSchema(fields) + return ResponseEntity.ok(schema) + } + +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchService.kt new file mode 100644 index 0000000..d7cd261 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/user/UserSearchService.kt @@ -0,0 +1,216 @@ +package com.nisemoe.nise.search.user + +import com.nisemoe.generated.tables.references.SCORES +import com.nisemoe.generated.tables.references.USERS +import com.nisemoe.nise.Format +import org.jooq.* +import org.jooq.impl.DSL +import org.springframework.stereotype.Service +import kotlin.math.roundToInt + +@Service +class UserSearchService( + private val dslContext: DSLContext +) { + + fun search(request: UserSearchController.SearchRequest): UserSearchController.SearchResponse { + var baseQuery = DSL.noCondition() + for (query in request.queries.filter { it.predicates.isNotEmpty() }) { + val condition = buildCondition(query) + baseQuery = when (query.logicalOperator.lowercase()) { + "and" -> baseQuery.and(condition) + "or" -> baseQuery.or(condition) + else -> throw IllegalArgumentException("Invalid logical operator") + } + } + + val selectFields = mutableListOf>( + USERS.USERNAME, + USERS.USER_ID, + USERS.JOIN_DATE, + USERS.COUNTRY, + USERS.COUNTRY_RANK, + USERS.RANK, + USERS.PP_RAW, + USERS.ACCURACY, + USERS.PLAYCOUNT, + USERS.TOTAL_SCORE, + USERS.RANKED_SCORE, + USERS.SECONDS_PLAYED, + USERS.COUNT_300, + USERS.COUNT_100, + USERS.COUNT_50, + USERS.COUNT_MISS, + USERS.IS_BANNED + ) + + val query = dslContext + .select(selectFields) + .from(USERS) + .where(baseQuery) + .apply { + if (request.sorting.field.isNotBlank()) + orderBy(buildSorting(request.sorting)) + } + .offset((request.page - 1) * UserSearchController.RESULTS_PER_PAGE) + .limit(UserSearchController.RESULTS_PER_PAGE) + + val results = query + .fetch() + + // Get total results + val totalResults = dslContext.selectCount() + .from(USERS) + .where(baseQuery) + .fetchOne(0, Int::class.java) ?: 0 + + return UserSearchController.SearchResponse( + results = mapRecordToScores(results), + pagination = UserSearchController.SearchResponsePagination( + currentPage = request.page, + pageSize = UserSearchController.RESULTS_PER_PAGE, + totalResults = totalResults + ) + ) + } + + private fun mapRecordToScores(results: Result): MutableList = + results.map { + UserSearchController.SearchResponseEntry( + user_id = it.get(SCORES.USER_ID), + username = it.get(USERS.USERNAME), + join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) }, + country = it.get(USERS.COUNTRY), + country_rank = it.get(USERS.COUNTRY_RANK), + rank = it.get(USERS.RANK), + pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(), + accuracy = it.get(USERS.ACCURACY), + playcount = it.get(USERS.PLAYCOUNT), + total_score = it.get(USERS.TOTAL_SCORE), + ranked_score = it.get(USERS.RANKED_SCORE), + seconds_played = it.get(USERS.SECONDS_PLAYED), + count_300 = it.get(USERS.COUNT_300), + count_100 = it.get(USERS.COUNT_100), + count_50 = it.get(USERS.COUNT_50), + count_miss = it.get(USERS.COUNT_MISS), + is_banned = it.get(USERS.IS_BANNED) + ) + } + + fun buildCondition(query: UserSearchController.SearchQuery): Condition { + // Handle base predicates + var baseCondition = buildPredicateCondition(query.predicates.first()) + query.predicates.drop(1).forEach { predicate -> + baseCondition = when (query.logicalOperator.lowercase()) { + "and" -> baseCondition.and(buildPredicateCondition(predicate)) + "or" -> baseCondition.or(buildPredicateCondition(predicate)) + else -> throw IllegalArgumentException("Invalid logical operator") + } + } + + // Handle child queries + if(query.childQueries.isNullOrEmpty()) + return baseCondition + + query.childQueries.forEach { childQuery -> + val childCondition = buildCondition(childQuery) // Recursively build condition for child queries + baseCondition = when (childQuery.logicalOperator.lowercase()) { + "and" -> baseCondition.and(childCondition) + "or" -> baseCondition.or(childCondition) + else -> throw IllegalArgumentException("Invalid logical operator") + } + } + + return baseCondition + } + + private fun buildSorting(sorting: UserSearchController.SearchSorting): OrderField<*> { + val field = mapPredicateFieldToDatabaseField(sorting.field) + return when (sorting.order.lowercase()) { + "asc" -> field.asc() + "desc" -> field.desc() + else -> throw IllegalArgumentException("Invalid sorting order") + } + } + + private fun buildPredicateCondition(predicate: UserSearchController.SearchPredicate): Condition { + val field = mapPredicateFieldToDatabaseField(predicate.field.name) + return when (predicate.field.type.lowercase()) { + "number" -> buildNumberCondition(field as Field, predicate.operator.operatorType, predicate.value.toDouble()) + "string" -> buildStringCondition(field as Field, predicate.operator.operatorType, predicate.value) + "boolean" -> buildBooleanCondition(field as Field, predicate.operator.operatorType, predicate.value.toBoolean()) + "flag" -> buildStringCondition(field as Field, predicate.operator.operatorType, predicate.value) + "grade" -> buildGradeCondition(field as Field, predicate.operator.operatorType, predicate.value) + "datetime" -> buildDatetimeCondition(field as Field, predicate.operator.operatorType, predicate.value) + "playtime" -> buildNumberCondition(field as Field, predicate.operator.operatorType, predicate.value.toDouble()) + else -> throw IllegalArgumentException("Invalid field type") + } + } + + private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> { + val databaseField = UserSearchSchemaController.internalFields.first { + it.name == predicateName && it.databaseField != null + } + return databaseField.databaseField!! + } + + private fun buildBooleanCondition(field: Field, operator: String, value: Boolean): Condition { + return when (operator) { + "=" -> field.eq(value) + "!=" -> field.ne(value) + else -> throw IllegalArgumentException("Invalid operator") + } + } + + private fun buildGradeCondition(field: Field, operator: String, value: String): Condition { + return when (value) { + "SS", "S", "A", "B", "C", "D" -> { + val valuesToMatch = when (value) { + "SS" -> listOf("Grade.SS", "Grade.SSH") + "S" -> listOf("Grade.S", "Grade.SH") + else -> listOf("Grade.$value") + } + when (operator) { + "=" -> field.`in`(valuesToMatch) + "!=" -> field.notIn(valuesToMatch) + else -> throw IllegalArgumentException("Invalid operator") + } + } + else -> throw IllegalArgumentException("Invalid grade value") + } + } + + private fun buildNumberCondition(field: Field, operator: String, value: Double): Condition { + return when (operator) { + "=" -> field.eq(value) + ">" -> field.gt(value) + "<" -> field.lt(value) + ">=" -> field.ge(value) + "<=" -> field.le(value) + "!=" -> field.ne(value) + else -> throw IllegalArgumentException("Invalid operator") + } + } + + private fun buildStringCondition(field: Field, operator: String, value: String): Condition { + return when (operator.lowercase()) { + "=" -> field.eq(value) + "contains" -> field.containsIgnoreCase(value) + "like" -> field.likeIgnoreCase( + // Escape special characters for LIKE if needed + value.replace("%", "\\%").replace("_", "\\_") + ) + + else -> throw IllegalArgumentException("Invalid operator") + } + } + + private fun buildDatetimeCondition(field: Field, operator: String, value: String): Condition { + return when (operator.lowercase()) { + "before" -> field.lessThan(value) + "after" -> field.greaterThan(value) + else -> throw IllegalArgumentException("Invalid operator") + } + } + +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/service/CompressReplay.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/service/CompressReplay.kt index 7382c3c..2fe8e2b 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/service/CompressReplay.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/service/CompressReplay.kt @@ -1,10 +1,12 @@ package com.nisemoe.nise.service -import com.aayushatharva.brotli4j.Brotli4jLoader -import com.aayushatharva.brotli4j.decoder.Decoder import com.aayushatharva.brotli4j.encoder.Encoder -import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream -import java.util.* +import com.aayushatharva.brotli4j.decoder.Decoder +import com.aayushatharva.brotli4j.Brotli4jLoader +import java.nio.ByteBuffer +import java.nio.ByteOrder + +data class ReplayStruct(val version: Int, val replayData: ByteArray) class CompressReplay { @@ -14,6 +16,8 @@ class CompressReplay { Brotli4jLoader.ensureAvailability() } + private const val CURRENT_VERSION = 2 + private val brotliParameters: Encoder.Parameters = Encoder.Parameters() .setQuality(11) @@ -22,17 +26,59 @@ class CompressReplay { } fun compressReplay(replay: ByteArray): ByteArray { -// val replayData = Base64.getDecoder().decode(replay).inputStream().use { byteStream -> -// LZMACompressorInputStream(byteStream).readBytes() -// } + val existingStruct = readReplayStruct(replay) + return if (existingStruct != null && existingStruct.version <= CURRENT_VERSION) { + replay + } else { + val compressedData = Encoder.compress(replay, brotliParameters) + val newStruct = ReplayStruct(CURRENT_VERSION, compressedData) + serializeReplayStruct(newStruct) + } + } - return Encoder.compress(replay, brotliParameters) + fun decompressReplayToString(replay: ByteArray): String { + return String(decompressReplay(replay), Charsets.UTF_8).trimEnd(',') } fun decompressReplay(replay: ByteArray): ByteArray { - return Decoder.decompress(replay).decompressedData + val replayStruct = readReplayStruct(replay) + return if (replayStruct != null) { + val decompressedResult = Decoder.decompress(replayStruct.replayData) + if (decompressedResult != null && decompressedResult.decompressedData != null) { + decompressedResult.decompressedData + } else { + replayStruct.replayData + } + } else { + replay + } + } + + private const val MAGIC_NUMBER = 0x12345678 // a unique signature to identify our format + + private fun readReplayStruct(data: ByteArray): ReplayStruct? { + if (data.size > 8) { // 4 bytes for the magic number + 4 bytes for the version + val buffer = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN) + val magic = buffer.int + if (magic == MAGIC_NUMBER) { + val version = buffer.int + val replayData = ByteArray(data.size - 8) + buffer.get(replayData) + return ReplayStruct(version, replayData) + } + } + return null + } + + private fun serializeReplayStruct(struct: ReplayStruct): ByteArray { + val buffer = ByteBuffer.allocate(8 + struct.replayData.size).order(ByteOrder.BIG_ENDIAN) + buffer.putInt(MAGIC_NUMBER) + buffer.putInt(struct.version) + buffer.put(struct.replayData) + val byteArray = buffer.array() + return byteArray } } -} \ No newline at end of file +} diff --git a/nise-backend/src/test/kotlin/com/nisemoe/nise/osu/Whatever.kt b/nise-backend/src/test/kotlin/com/nisemoe/nise/osu/Whatever.kt index 232e176..555e468 100644 --- a/nise-backend/src/test/kotlin/com/nisemoe/nise/osu/Whatever.kt +++ b/nise-backend/src/test/kotlin/com/nisemoe/nise/osu/Whatever.kt @@ -1,69 +1,61 @@ package com.nisemoe.nise.osu -import com.nisemoe.nise.service.CompressReplay -import com.nisemoe.generated.tables.Scores.Companion.SCORES -import com.nisemoe.generated.tables.records.ScoresRecord -import com.nisemoe.nise.database.UserService import com.nisemoe.nise.konata.tools.getEvents -import com.nisemoe.nise.konata.tools.processEvents -import com.nisemoe.nise.scheduler.GlobalCache -import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream -import org.jooq.DSLContext -import org.jooq.impl.DSL +import com.nisemoe.nise.service.CompressReplay import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.test.context.ActiveProfiles -import java.util.* +import kotlin.test.assertEquals - -@SpringBootTest -@ActiveProfiles("postgres") -@MockBean(GlobalCache::class, UserService::class) @Disabled class Whatever { private val logger = LoggerFactory.getLogger(javaClass) - @Autowired - private lateinit var dslContext: DSLContext + private val replayString = "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" @Test - fun compressAndVerifyBulkWithB64Decode() { - val scores = dslContext.select() - .from(SCORES) - .where(SCORES.REPLAY.isNotNull) - .orderBy(DSL.rand()) - .fetchInto(ScoresRecord::class.java) + fun compressNativeReplay() { + val compressed = CompressReplay.compressReplay(replayString) - for(score in scores) { - val replayString = score.replay!! - val replay = Base64.getDecoder().decode(replayString).inputStream().use { byteStream -> - LZMACompressorInputStream(byteStream).readBytes() - } + logger.info("Compressed replay collection from ${replayString.length} bytes to ${compressed.size} bytes") + val spaceSaved = (1 - compressed.size.toDouble() / replayString.length) * 100 + logger.info("Space saved: %.2f%%".format(spaceSaved)) - val compressed = CompressReplay.compressReplay(score.replay!!) + val decompressed = CompressReplay.decompressReplay(compressed) - logger.info("Compressed replay collection from ${score.replay!!.size} bytes to ${compressed.size} bytes") - val spaceSaved = (1 - compressed.size.toDouble() / score.replay!!.size) * 100 - logger.info("Space saved: %.2f%%".format(spaceSaved)) + val replayString2 = String(decompressed, Charsets.UTF_8).trimEnd(',') - val decompressed = CompressReplay.decompressReplay(compressed) + val replayEvents = getEvents(replayString) + val decompressedEvents = getEvents(replayString2) - assert(replay.size > compressed.size) - assert(replay.contentEquals(decompressed)) + assert(replayEvents.size == decompressedEvents.size) + } - val replayString1 = String(replayString, Charsets.UTF_8).trimEnd(',') - val replayString2 = String(decompressed, Charsets.UTF_8).trimEnd(',') + @Test + fun compressAlreadyCompressedReplay() { + val compressed = CompressReplay.compressReplay(replayString) + val compressed2 = CompressReplay.compressReplay(compressed) - val replayEvents = getEvents(replayString1) - val decompressedEvents = processEvents(replayString2) + assertEquals(compressed, compressed2) + } - assert(replayEvents.size == decompressedEvents.size) - } + @Test + fun decompressNativeReplay() { + val decompressed = CompressReplay.decompressReplayToString(replayString.toByteArray()) + + val replayEvents = getEvents(replayString) + val decompressedEvents = getEvents(decompressed) + + assert(replayEvents.size == decompressedEvents.size) + } + + @Test + fun decompressAlreadyDecompressedReplay() { + val decompressed = CompressReplay.decompressReplay(replayString.toByteArray()) + val decompressed2 = CompressReplay.decompressReplay(decompressed) + + assertEquals(decompressed, decompressed2) } } \ No newline at end of file diff --git a/nise-frontend/src/app/api/api.component.html b/nise-frontend/src/app/api/api.component.html index 7ec006e..2779cbb 100644 --- a/nise-frontend/src/app/api/api.component.html +++ b/nise-frontend/src/app/api/api.component.html @@ -18,7 +18,35 @@

## scores search

score search is based on predicates. a predicate is a list of specifications/conditions to match results. predicates can be combined with operators such as AND and OR

-

COMING SOON; the route exists but its cancer for api users to form the requests.

+
    +
  • ENDPOINT: /api/search
  • +
  • METHOD: POST
  • +
  • POST FORMAT: JSON only
  • +
  • POST BODY: use the score search page and click on export POST for /api/ to fabricate a request. its easier.
  • +
+

[!] set your request timeout to high values since some complex queries can take a while.

+ Example: +
+ + curl -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"queries":[{"predicates":[{"field":{"name":"user_rank","type":"number"},"operator":{"operatorType":"<","acceptsValues":"any"},"value":"50"},{"field":{"name":"ur","type":"number"},"operator":{"operatorType":"<","acceptsValues":"any"},"value":"120"}],"logicalOperator":"AND"}],"sorting":{"field":"user_id","order":"ASC"},"page":1}' https://nise.moe/api/search + +
+ +
+

## users search

+

exactly like the scores search, but for users.

+
    +
  • ENDPOINT: /api/search-user
  • +
  • METHOD: POST
  • +
  • POST FORMAT: JSON only
  • +
  • POST BODY: use the user search page and click on export POST for /api/ to fabricate a request. its easier.
  • +
+

[!] set your request timeout to high values since some complex queries can take a while.

+ Example: +
+ + curl -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"queries":[{"predicates":[{"field":{"name":"rank","type":"number"},"operator":{"operatorType":">","acceptsValues":"any"},"value":"10"},{"field":{"name":"username","type":"string"},"operator":{"operatorType":"=","acceptsValues":"any"},"value":"degenerate"}],"logicalOperator":"AND"}],"sorting":{"field":"user_id","order":"ASC"},"page":1}' https://nise.moe/api/search-user +
@@ -59,18 +87,22 @@

## get user details

-

if you have an userId, you can retrieve everything we know 'bout that user. -

>> userId is the username. sry.

+

if you have an userId or username, you can retrieve everything we know 'bout that user. +

>> only pass EITHER of [userId, username], not both.

  • ENDPOINT: /api/user-details
  • METHOD: POST
  • -
  • POST FIELDS: userId: str (*required. case insensitive)
  • +
  • POST FIELDS: userId: long〖OR〗username: str (case insensitive)
  • POST FORMAT: JSON only
Example:
- curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"userId": "degenerate"}' https://nise.moe/api/user-details + curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"username": "degenerate"}' https://nise.moe/api/user-details + +

+ + curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"userId": 8184689}' https://nise.moe/api/user-details
@@ -150,11 +182,11 @@ curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -F "replay=@replay1.osr" https://nise.moe/api/analyze -

the response will include an id parameter, which identifies the replay you've uploaded.

+

the response will include an id parameter (str), which identifies the replay you've uploaded.

that id will be subsequently available at:

  • WEB INTERFACE: https://nise.moe/c/{id}
  • -
  • API: https://nise.moe/api/user-scores/{id}
  • +
  • API: https://nise.moe/api/user-scores/{id} GET
diff --git a/nise-frontend/src/app/api/api.component.ts b/nise-frontend/src/app/api/api.component.ts index cba9515..81b9604 100644 --- a/nise-frontend/src/app/api/api.component.ts +++ b/nise-frontend/src/app/api/api.component.ts @@ -5,6 +5,7 @@ import {DatePipe, DecimalPipe, NgForOf, NgIf} from "@angular/common"; import { CodeWithCopyButtonComponent } from "../../corelib/components/code-with-copy-button/code-with-copy-button.component"; +import {RouterLink} from "@angular/router"; @Component({ selector: 'app-api', @@ -16,7 +17,8 @@ import { DecimalPipe, NgForOf, NgIf, - CodeWithCopyButtonComponent + CodeWithCopyButtonComponent, + RouterLink ], templateUrl: './api.component.html', styleUrl: './api.component.css' diff --git a/nise-frontend/src/app/app-routing.module.ts b/nise-frontend/src/app/app-routing.module.ts index f2fc2ad..6b8a4ce 100644 --- a/nise-frontend/src/app/app-routing.module.ts +++ b/nise-frontend/src/app/app-routing.module.ts @@ -22,7 +22,9 @@ const routes: Routes = [ {path: 'u/:userId', component: ViewUserComponent}, {path: 's/:replayId', component: ViewScoreComponent}, {path: 'c/:userReplayId', component: ViewScoreComponent}, - {path: 'search', component: SearchComponent}, + + {path: 'search', component: SearchComponent, data: { searchType: 'score' }}, + {path: 'user-search', component: SearchComponent, data: { searchType: 'user' }}, {path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent}, diff --git a/nise-frontend/src/app/search/search.component.css b/nise-frontend/src/app/search/search.component.css index 97cf837..ab6fdf5 100644 --- a/nise-frontend/src/app/search/search.component.css +++ b/nise-frontend/src/app/search/search.component.css @@ -19,3 +19,18 @@ .score-entry:hover { background-color: rgba(179, 184, 195, 0.15); } + +.link-list a { + padding: 20px; +} + +.link-list a:hover { + color: white; + background-color: rgba(47, 47, 47, 0.46); +} + +.disabled { + cursor: not-allowed; + pointer-events: none; + background-color: rgba(179, 184, 195, 0.15); +} diff --git a/nise-frontend/src/app/search/search.component.html b/nise-frontend/src/app/search/search.component.html index 210069c..447dc09 100644 --- a/nise-frontend/src/app/search/search.component.html +++ b/nise-frontend/src/app/search/search.component.html @@ -1,180 +1,201 @@ -
-

/k/ - Advanced Search

+
- -
-

Loading schema...

-
-
- -
- Table columns - -
- {{ category }} - -
- -
-
-
-
+
+ + /k/ - score search + -
- -
+ + /m/ - user search + -
- sorting +
- - - - - -
- - - -
-
- -
- - +
+
-

Loading

-

please be patient - the database is working hard!

+

Loading schema...

+ +
+ Table columns + + +
+ {{ category }} + + + + +
+ +
+
+
+
+
-
-

Looks like something went wrong... :(

-

I'll look into what caused the error - but feel free to get in touch.

-
+
- - -
-

No results for your query - try different parameters.

-
-
+
+ +
- -
- tools -
- - - -
-
-
- - - - - - - - - - - - - -
- {{ column.shortName }} - Links
- - - {{ getValue(entry, column.name) | number }} - - - {{ countryCodeToFlag(getValue(entry, column.name)) }} - - - - - - {{ getValue(entry, column.name) }} - - - - ✓ - - - ✗ - - - - {{ formatDuration(getValue(entry, column.name)) }} - - - - - {{ getValue(entry, column.name) }} - - - {{ getValue(entry, column.name) }} - +
+ sorting +
- - details - -
-
+ +
+
+ + + + -
-

Total results: {{ response.pagination.totalResults | number }}

-

Page: {{ response.pagination.currentPage | number }} / {{ response.pagination.totalPages | number }}

-
- - ... - - ... - -
- - +
+ + + + +
+
+ +
+ + +
+

Loading

+

please be patient - the database is working hard!

- - +
+

Looks like something went wrong... :(

+

I'll look into what caused the error - but feel free to get in touch.

+
+ + +
+

No results for your query - try different parameters.

+
+
+ + +
+ tools +
+ + + +
+
+
+ + + + + + + + + + + + + +
+ {{ column.shortName }} + Links
+ + + {{ getValue(entry, column.name) | number }} + + + {{ countryCodeToFlag(getValue(entry, column.name)) }} + + + + + + {{ getValue(entry, column.name) }} + + + + ✓ + + + ✗ + + + + {{ formatDuration(getValue(entry, column.name)) }} + + + + + {{ getValue(entry, column.name) }} + + + {{ getValue(entry, column.name) }} + + + + + null + + + details + +
+
+ +
+

Total results: {{ response.pagination.totalResults | number }}

+

Page: {{ response.pagination.currentPage | number }} / {{ response.pagination.totalPages | number }}

+
+ + ... + + ... + +
+ + +
+
+ +
+ + +
diff --git a/nise-frontend/src/app/search/search.component.ts b/nise-frontend/src/app/search/search.component.ts index 9986343..4c32ece 100644 --- a/nise-frontend/src/app/search/search.component.ts +++ b/nise-frontend/src/app/search/search.component.ts @@ -11,7 +11,7 @@ import { Query, QueryBuilderComponent } from "../../corelib/components/query-builder/query-builder.component"; -import {RouterLink} from "@angular/router"; +import {ActivatedRoute, RouterLink} from "@angular/router"; import {CalculatePageRangePipe} from "../../corelib/calculate-page-range.pipe"; import {DownloadFilesService} from "../../corelib/service/download-files.service"; import {CuteLoadingComponent} from "../../corelib/components/cute-loading/cute-loading.component"; @@ -32,7 +32,7 @@ interface SchemaResponse { } interface SearchResponse { - scores: any[]; + results: any[]; pagination: SearchPagination; } @@ -69,8 +69,15 @@ interface Sorting { }) export class SearchComponent implements OnInit { + schemaUrl!: string; + searchUrl!: string; + pageTitle!: string; + localStorageKey!: string; + searchType!: string; + constructor(private httpClient: HttpClient, private title: Title, + private route: ActivatedRoute, public downloadFilesService: DownloadFilesService) { } currentSchemaVersion = 2 @@ -86,23 +93,44 @@ export class SearchComponent implements OnInit { queries: Query[] | null = null; ngOnInit(): void { - this.title.setTitle("/k/ - advanced search"); - this.isLoadingSchema = true; - this.httpClient.get(`${environment.apiUrl}/search/schema`,).subscribe({ - next: (response) => { - this.fields = response.fields; - this.fields.forEach(field => { - field.validOperators = this.getOperators(field.type); - }) - this.loadPreviousFromLocalStorage(); - this.isLoadingSchema = false; - }, - error: () => { - alert('Error fetching schema'); + this.route.data.subscribe(data => { + const searchType = data['searchType']; + this.searchType = searchType; + + if (searchType === 'user') { + this.schemaUrl = `${environment.apiUrl}/search-user/schema`; + this.searchUrl = `${environment.apiUrl}/search-user`; + this.pageTitle = '/m/ - user search'; + this.localStorageKey = 'user_search_settings'; + } else { + this.schemaUrl = `${environment.apiUrl}/search/schema`; + this.searchUrl = `${environment.apiUrl}/search`; + this.pageTitle = '/k/ - score search'; + this.localStorageKey = 'search_settings'; } + + this.title.setTitle(this.pageTitle); + this.isLoadingSchema = true; + this.httpClient.get(this.schemaUrl).subscribe({ + next: (response) => { + this.fields = response.fields; + this.fields.forEach(field => { + field.validOperators = this.getOperators(field.type); + }) + this.loadPreviousFromLocalStorage(); + this.isLoadingSchema = false; + }, + error: () => { + alert('Error fetching schema'); + } + }); }); } + hasFieldsInCategory(category: string): boolean { + return this.fields.some(field => field.category === category); + } + getOperators(fieldType: FieldType | undefined): Operator[] { switch (fieldType) { case 'number': @@ -132,7 +160,7 @@ export class SearchComponent implements OnInit { } private loadPreviousFromLocalStorage(): void { - const storedQueries = localStorage.getItem('search_settings'); + const storedQueries = localStorage.getItem(this.localStorageKey); let parsedQueries = storedQueries ? JSON.parse(storedQueries) : null; if (parsedQueries && this.verifySchema(parsedQueries)) { @@ -142,7 +170,7 @@ export class SearchComponent implements OnInit { field.active = parsedQueries.columns[field.name] ?? field.active; }); } else { - localStorage.removeItem('search_settings'); + localStorage.removeItem(this.localStorageKey); this.queries = []; this.sortingOrder = { field: 'user_id', @@ -203,7 +231,36 @@ export class SearchComponent implements OnInit { saveSettingsToLocalStorage(): void { const settings = this.serializeSettings(); - localStorage.setItem('search_settings', JSON.stringify(settings)); + localStorage.setItem(this.localStorageKey, JSON.stringify(settings)); + } + + exportForApi(): void { + if(!this.queries) { + return; + } + + const body = { + queries: this.queries.map(query => ({ + ...query, + predicates: query.predicates.map(predicate => ({ + field: { + name: predicate.field!!.name, + type: predicate.field!!.type + }, + operator: predicate.operator, + value: predicate.value + })) + })), + sorting: this.sortingOrder, + page: 1 + }; + + // Copy to cliboard + navigator.clipboard.writeText(JSON.stringify(body)).then(() => { + alert('Copied to clipboard'); + }, () => { + alert('Error copying to clipboard'); + }); } exportSettings(): void { @@ -262,7 +319,7 @@ export class SearchComponent implements OnInit { sorting: this.sortingOrder, page: pageNumber } - this.httpClient.post(`${environment.apiUrl}/search`, body) + this.httpClient.post(this.searchUrl, body) .subscribe({ next: (response) => { this.response = response; @@ -284,8 +341,12 @@ export class SearchComponent implements OnInit { } } - getId(entry: any): any { - return this.getValue(entry, 'replay_id'); + getLink(entry: any): any { + if(this.searchType === 'user') { + return "/u/" + this.getValue(entry, 'username'); + } else { + return "/s/" + this.getValue(entry, 'replay_id'); + } } protected readonly countryCodeToFlag = countryCodeToFlag; diff --git a/nise-frontend/src/app/view-user/view-user.component.ts b/nise-frontend/src/app/view-user/view-user.component.ts index 9e1feed..4ef05d4 100644 --- a/nise-frontend/src/app/view-user/view-user.component.ts +++ b/nise-frontend/src/app/view-user/view-user.component.ts @@ -74,7 +74,7 @@ export class ViewUserComponent implements OnInit, OnChanges, OnDestroy { getUserInfo(): Observable { const body = { - userId: this.userId + username: this.userId } return this.httpClient.post(`${environment.apiUrl}/user-details`, body); } diff --git a/nise-frontend/src/corelib/components/query/query.component.html b/nise-frontend/src/corelib/components/query/query.component.html index 896000a..173a8b1 100644 --- a/nise-frontend/src/corelib/components/query/query.component.html +++ b/nise-frontend/src/corelib/components/query/query.component.html @@ -23,15 +23,17 @@ diff --git a/nise-frontend/src/corelib/components/query/query.component.ts b/nise-frontend/src/corelib/components/query/query.component.ts index d13d8e7..43a6c05 100644 --- a/nise-frontend/src/corelib/components/query/query.component.ts +++ b/nise-frontend/src/corelib/components/query/query.component.ts @@ -43,6 +43,10 @@ export class QueryComponent { predicate.operator = selectedField.validOperators[0]; } + hasFieldsInCategory(category: string): boolean { + return this.fields.some(field => field.category === category); + } + onOperatorChange(predicate: Predicate, event: Event): void { const selectElement = event.target as HTMLSelectElement; const selectedOperatorType = selectElement.value;