Added user search, improved api docs, improved replay compression by 20-25%, user-details allows userId and username

This commit is contained in:
nise.moe 2024-06-11 13:35:07 +02:00
parent 5fbdfaa322
commit ebaf4c82c5
27 changed files with 1037 additions and 412 deletions

View File

@ -155,6 +155,18 @@
<version>1.9</version>
</dependency>
<!-- Graphs/Plots -->
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kandy-lets-plot</artifactId>
<version>0.6.0</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlin-statistics-jvm</artifactId>
<version>0.2.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
@ -269,4 +281,12 @@
</plugins>
</build>
<repositories>
<repository>
<id>jetbrains</id>
<name>jetbrains</name>
<url>https://packages.jetbrains.team/maven/p/kds/kotlin-ds-maven</url>
</repository>
</repositories>
</project>

View File

@ -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)

View File

@ -37,7 +37,7 @@ class UserDetailsController(
@PostMapping("user-queue")
fun addUserToQueue(@RequestBody request: UserQueueRequest): ResponseEntity<Unit> {
// 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<UserDetailsResponse> {
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

View File

@ -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
)

View File

@ -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)

View File

@ -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<Plot>()
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<Double>?,
val KEYPRESSES_TIMES: Array<Double>?
)
val ppWeight = 1.0

View File

@ -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()
// }
}

View File

@ -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<ReplaySetComparison> = 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) {

View File

@ -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<KClass<out Any>> = []
)
class ChildQueriesDepthValidator : ConstraintValidator<ValidChildQueriesDepth, List<SearchController.SearchQuery>> {
class ChildQueriesDepthValidator : ConstraintValidator<ValidChildQueriesDepth, List<ScoreSearchController.SearchQuery>> {
override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) {
super.initialize(constraintAnnotation)
}
override fun isValid(queries: List<SearchController.SearchQuery>?, context: ConstraintValidatorContext): Boolean {
override fun isValid(queries: List<ScoreSearchController.SearchQuery>?, 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

View File

@ -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<SearchResponseEntry>,
val results: List<SearchResponseEntry>,
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())

View File

@ -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),

View File

@ -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<Record>): MutableList<SearchController.SearchResponseEntry> =
private fun mapRecordToScores(results: Result<Record>): MutableList<ScoreSearchController.SearchResponseEntry> =
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<Double>, 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!!

View File

@ -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<KClass<*>> = [],
val payload: Array<KClass<out Any>> = []
)
class UserChildQueriesDepthValidator : ConstraintValidator<ValidChildQueriesDepth, List<UserSearchController.SearchQuery>> {
override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) {
super.initialize(constraintAnnotation)
}
override fun isValid(queries: List<UserSearchController.SearchQuery>?, 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
}
}

View File

@ -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<SearchResponseEntry>,
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<SearchQuery>,
@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<SearchPredicate>,
@Valid @field:ValidChildQueriesDepth @field:Size(max = 10) val childQueries: List<SearchQuery>?
)
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<SearchResponse> {
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))
}
}
}

View File

@ -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<SchemaField>
)
@GetMapping("search-user/schema")
fun getSearchSchema(): ResponseEntity<SearchSchema> {
// 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)
}
}

View File

@ -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<Field<*>>(
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<Record>): MutableList<UserSearchController.SearchResponseEntry> =
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<Double>, predicate.operator.operatorType, predicate.value.toDouble())
"string" -> buildStringCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
"boolean" -> buildBooleanCondition(field as Field<Boolean>, predicate.operator.operatorType, predicate.value.toBoolean())
"flag" -> buildStringCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
"grade" -> buildGradeCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
"datetime" -> buildDatetimeCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
"playtime" -> buildNumberCondition(field as Field<Double>, 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<Boolean>, operator: String, value: Boolean): Condition {
return when (operator) {
"=" -> field.eq(value)
"!=" -> field.ne(value)
else -> throw IllegalArgumentException("Invalid operator")
}
}
private fun buildGradeCondition(field: Field<String>, 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<Double>, 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<String>, 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<String>, operator: String, value: String): Condition {
return when (operator.lowercase()) {
"before" -> field.lessThan(value)
"after" -> field.greaterThan(value)
else -> throw IllegalArgumentException("Invalid operator")
}
}
}

View File

@ -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,15 +26,57 @@ 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
}
}

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,35 @@
<section>
<h1 class="mt-4">## scores search</h1>
<p>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 <code>AND</code> and <code>OR</code></p>
<p style="font-weight: bold; color: orange">COMING SOON; the route exists but its cancer for api users to form the requests.</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/search</code></li>
<li><strong>METHOD:</strong> POST</li>
<li><strong>POST FORMAT:</strong> JSON only</li>
<li><strong>POST BODY:</strong> use the <a [routerLink]="['/search']">score search</a> page and click on <code>export POST for /api/</code> to fabricate a request. its easier.</li>
</ul>
<p style="font-weight: bold; color: orange"><span style="font-size: 20px">[!]</span> set your request timeout to high values since some complex queries can take a while.</p>
Example:
<br>
<app-code-with-copy-button>
curl -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '&#123;"queries"&#58;[&#123;"predicates"&#58;[&#123;"field"&#58;&#123;"name"&#58;"user_rank","type"&#58;"number"&#125;,"operator"&#58;&#123;"operatorType"&#58;"<","acceptsValues"&#58;"any"&#125;,"value"&#58;"50"&#125;,&#123;"field"&#58;&#123;"name"&#58;"ur","type"&#58;"number"&#125;,"operator"&#58;&#123;"operatorType"&#58;"<","acceptsValues"&#58;"any"&#125;,"value"&#58;"120"&#125;&#93;,"logicalOperator"&#58;"AND"&#125;&#93;,"sorting"&#58;&#123;"field"&#58;"user_id","order"&#58;"ASC"&#125;,"page"&#58;1&#125;' https://nise.moe/api/search
</app-code-with-copy-button>
</section>
<section>
<h1 class="mt-4">## users search</h1>
<p>exactly like the <i>scores search</i>, but for users.</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/search-user</code></li>
<li><strong>METHOD:</strong> POST</li>
<li><strong>POST FORMAT:</strong> JSON only</li>
<li><strong>POST BODY:</strong> use the <a [routerLink]="['/user-search']">user search</a> page and click on <code>export POST for /api/</code> to fabricate a request. its easier.</li>
</ul>
<p style="font-weight: bold; color: orange"><span style="font-size: 20px">[!]</span> set your request timeout to high values since some complex queries can take a while.</p>
Example:
<br>
<app-code-with-copy-button>
curl -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '&#123;"queries"&#58;[&#123;"predicates"&#58;[&#123;"field"&#58;&#123;"name"&#58;"rank","type"&#58;"number"&#125;,"operator"&#58;&#123;"operatorType"&#58;">","acceptsValues"&#58;"any"&#125;,"value"&#58;"10"&#125;,&#123;"field"&#58;&#123;"name"&#58;"username","type"&#58;"string"&#125;,"operator"&#58;&#123;"operatorType"&#58;"=","acceptsValues"&#58;"any"&#125;,"value"&#58;"degenerate"&#125;&#93;,"logicalOperator"&#58;"AND"&#125;&#93;,"sorting"&#58;&#123;"field"&#58;"user_id","order"&#58;"ASC"&#125;,"page"&#58;1&#125;' https://nise.moe/api/search-user
</app-code-with-copy-button>
</section>
<section>
@ -59,18 +87,22 @@
<section>
<h1 class="mt-4">## get user details</h1>
<p>if you have an <code>userId</code>, you can retrieve everything we know 'bout that user.
<p style="font-weight: bold; color: #fa5c5c">>> <strong>userId</strong> is the username. sry.</p>
<p>if you have an <code>userId</code> or <code>username</code>, you can retrieve everything we know 'bout that user.
<p style="font-weight: bold; color: #fa5c5c">>> only pass EITHER of [userId, username], not both.</p>
<ul>
<li><strong>ENDPOINT:</strong> <code>/api/user-details</code></li>
<li><strong>METHOD:</strong> POST</li>
<li><strong>POST FIELDS:</strong> userId: str (*required. case insensitive)</li>
<li><strong>POST FIELDS:</strong> userId: long〖OR〗username: str (case insensitive)</li>
<li><strong>POST FORMAT:</strong> JSON only</li>
</ul>
Example:
<br>
<app-code-with-copy-button>
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '&#123;"userId": "degenerate"&#125;' https://nise.moe/api/user-details
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '&#123;"username": "degenerate"&#125;' https://nise.moe/api/user-details
</app-code-with-copy-button>
<br><br>
<app-code-with-copy-button>
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -H "Content-Type: application/json" -d '&#123;"userId": 8184689&#125;' https://nise.moe/api/user-details
</app-code-with-copy-button>
</section>
@ -150,11 +182,11 @@
<app-code-with-copy-button>
curl -X POST -H "X-NISE-API: 20240218" -H "Accept: application/json" -F "replay=&#64;replay1.osr" https://nise.moe/api/analyze
</app-code-with-copy-button>
<p>the response will include an <code>id</code> parameter, which identifies the replay you've uploaded.</p>
<p>the response will include an <code>id</code> parameter (str), which identifies the replay you've uploaded.</p>
<p>that id will be subsequently available at:</p>
<ul>
<li><strong>WEB INTERFACE</strong>: https://nise.moe/c/&#123;id&#125;</li>
<li><strong>API:</strong> https://nise.moe/api/user-scores/&#123;id&#125;</li>
<li><strong>API:</strong> https://nise.moe/api/user-scores/&#123;id&#125; <code>GET</code></li>
</ul>
</section>

View File

@ -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'

View File

@ -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},

View File

@ -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);
}

View File

@ -1,180 +1,201 @@
<div class="main term">
<h1><span class="board">/k/</span> - Advanced Search</h1>
<div class="main term" style="padding: 0; width: 882px !important;">
<ng-container *ngIf="this.isLoadingSchema; else searchPanel">
<div class="text-center">
<p>Loading schema...</p>
</div>
</ng-container>
<ng-template #searchPanel>
<fieldset>
<legend>Table columns</legend>
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
<fieldset class="mb-2">
<legend>{{ category }} <button (click)="this.selectEntireFieldCategory(category)">Select all</button> <button (click)="this.deselectEntireFieldCategory(category)">Deselect all</button></legend>
<ng-container *ngFor="let field of fields">
<div *ngIf="field.category === category">
<label>
<input type="checkbox" [(ngModel)]="field.active" (change)="this.saveSettingsToLocalStorage()"/>
{{ field.name }} <span class="text-muted" style="margin-left: 6px">{{ field.description }}</span>
</label>
</div>
</ng-container>
</fieldset>
</ng-container>
<div style="display: flex; justify-content: space-between; margin-bottom: 10px" class="link-list">
</fieldset>
<a style="flex: 1; font-size: 18px; text-decoration-color: rgba(255,255,255,0.32)" class="text-center" [routerLink]="['/search']" [class.disabled]="this.searchType == 'score'">
<span class="board" style="font-size: 18px; letter-spacing: 2px;">/k/</span> - score search
</a>
<div class="search-container mt-2" *ngIf="this.queries">
<app-query-builder [queries]="this.queries" [fields]="fields"></app-query-builder>
</div>
<a style="flex: 1; font-size: 18px; text-decoration-color: rgba(255,255,255,0.32)" class="text-center" [routerLink]="['/user-search']" [class.disabled]="this.searchType == 'user'">
<span class="board" style="font-size: 18px; letter-spacing: 2px">/m/</span> - user search
</a>
<fieldset class="mt-2" *ngIf="this.sortingOrder">
<legend>sorting</legend>
</div>
<select (change)="onSortingFieldChange($event)">
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
<optgroup label="{{ category }}">
<ng-container *ngFor="let field of fields">
<ng-container *ngIf="field.category === category">
<option
[value]="field.name"
[selected]="field.name === sortingOrder.field">
{{ field.name }}
</option>
</ng-container>
</ng-container>
</optgroup>
</ng-container>
</select>
<label style="margin-left: 8px">
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
↑ ASC
</label>
<label>
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="DESC" />
↓ DESC
</label>
</fieldset>
<div class="text-center mt-2">
<button (click)="exportSettings()">Export settings</button>
<button (click)="fileInput.click()" style="margin-left: 5px">Import settings</button>
<input type="file" #fileInput style="display: none" (change)="uploadSettingsFile($event)" accept=".json">
</div>
<div class="text-center mt-1">
<button (click)="search()" [disabled]="this.isLoading" class="mb-2" style="font-size: 18px">Search</button>
</div>
<ng-container *ngIf="this.isLoading">
<div style="padding: 16px">
<ng-container *ngIf="this.isLoadingSchema; else searchPanel">
<div class="text-center">
<p>Loading <app-cute-loading></app-cute-loading></p>
<p>please be patient - the database is working hard!</p>
<p>Loading schema...</p>
</div>
</ng-container>
<ng-template #searchPanel>
<fieldset>
<legend>Table columns</legend>
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
<ng-container *ngIf="hasFieldsInCategory(category)">
<fieldset class="mb-2">
<legend>{{ category }}
<button (click)="this.selectEntireFieldCategory(category)">Select all</button>
<button (click)="this.deselectEntireFieldCategory(category)">Deselect all</button>
</legend>
<ng-container *ngFor="let field of fields">
<div *ngIf="field.category === category">
<label>
<input type="checkbox" [(ngModel)]="field.active" (change)="this.saveSettingsToLocalStorage()"/>
{{ field.name }} <span class="text-muted" style="margin-left: 6px">{{ field.description }}</span>
</label>
</div>
</ng-container>
</fieldset>
</ng-container>
</ng-container>
<div class="text-center alert-error" *ngIf="this.isError">
<p>Looks like something went wrong... :(</p>
<p>I'll look into what caused the error - but feel free to get in touch.</p>
</div>
</fieldset>
<ng-container *ngIf="response">
<ng-container *ngIf="response.scores.length <= 0">
<div class="text-center alert-error">
<p>No results for your query - try different parameters.</p>
</div>
</ng-container>
<div class="search-container mt-2" *ngIf="this.queries">
<app-query-builder [queries]="this.queries" [fields]="fields"></app-query-builder>
</div>
<ng-container *ngIf="response.scores.length > 0">
<fieldset class="mb-2">
<legend>tools</legend>
<div class="text-center">
<button (click)="this.downloadFilesService.downloadCSV(response.scores, getColumns(), 'nise-search')">Download .csv</button>
<button (click)="this.downloadFilesService.downloadJSON(response.scores, 'nise-search')">Download .json</button>
<button (click)="this.downloadFilesService.downloadXLSX(response.scores, 'nise-search')">Download .xlsx</button>
</div>
</fieldset>
<div class="scrollable-table">
<table class="table-border">
<thead>
<tr>
<th *ngFor="let column of fields" [hidden]="!column.active" class="text-center">
{{ column.shortName }}
</th>
<th>Links</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let entry of response.scores" class="score-entry">
<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 }}
</ng-container>
<ng-container *ngIf="column.type == 'flag'">
<span class="flag" [title]="getValue(entry, column.name)">{{ countryCodeToFlag(getValue(entry, column.name)) }}</span>
</ng-container>
<ng-container *ngIf="column.type == 'grade'">
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
</ng-container>
<ng-container *ngIf="column.type == 'datetime'">
{{ getValue(entry, column.name) }}
</ng-container>
<ng-container *ngIf="column.type == 'boolean'">
<ng-container *ngIf="getValue(entry, column.name) == true">
</ng-container>
<ng-container *ngIf="getValue(entry, column.name) == false">
</ng-container>
</ng-container>
<ng-container *ngIf="column.type == 'playtime'">
{{ formatDuration(getValue(entry, column.name)) }}
</ng-container>
<ng-container *ngIf="column.type == 'string'">
<ng-container *ngIf="column.name == 'user_username'; else stringField">
<a [href]="'/u/' + getValue(entry, column.name)" target="_blank">{{ getValue(entry, column.name) }}</a>
</ng-container>
<ng-template #stringField>
{{ getValue(entry, column.name) }}
</ng-template>
<fieldset class="mt-2" *ngIf="this.sortingOrder">
<legend>sorting</legend>
<select (change)="onSortingFieldChange($event)">
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
<ng-container *ngIf="hasFieldsInCategory(category)">
<optgroup label="{{ category }}">
<ng-container *ngFor="let field of fields">
<ng-container *ngIf="field.category === category">
<option
[value]="field.name"
[selected]="field.name === sortingOrder.field">
{{ field.name }}
</option>
</ng-container>
</ng-container>
<ng-template #nullDisplay><code>null</code></ng-template>
</td>
<td class="text-center" style="line-height: 32px">
<a [href]="'/s/' + this.getId(entry)" target="_blank">
details
</a>
</td>
</tr>
</tbody>
</table>
</div>
</optgroup>
</ng-container>
</ng-container>
</select>
<label style="margin-left: 8px">
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
↑ ASC
</label>
<label>
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="DESC" />
↓ DESC
</label>
</fieldset>
<div class="text-center mt-2">
<p>Total results: {{ response.pagination.totalResults | number }}</p>
<p>Page: {{ response.pagination.currentPage | number }} / {{ response.pagination.totalPages | number }}</p>
<div class="mb-2">
<button *ngIf="response.pagination.currentPage > 5" (click)="this.search(1)" style="margin-right: 5px">1</button>
<span *ngIf="response.pagination.currentPage > 6">... </span>
<button *ngFor="let page of [].constructor(Math.min(response.pagination.totalPages, 10)) | calculatePageRange:response.pagination.currentPage:response.pagination.totalPages; let i = index"
(click)="this.search(page)"
[disabled]="page == response.pagination.currentPage"
style="margin-right: 5px">
{{ page }}
</button>
<span *ngIf="response.pagination.currentPage < response.pagination.totalPages - 5">... </span>
<button *ngIf="response.pagination.currentPage < response.pagination.totalPages - 4" (click)="this.search(response.pagination.totalPages)" style="margin-right: 5px">{{ response.pagination.totalPages }}</button>
</div>
<button (click)="this.search(response.pagination.currentPage - 1)" [disabled]="response.pagination.currentPage == 1">← Previous</button>
<button (click)="this.search(response.pagination.currentPage + 1)" [disabled]="response.pagination.currentPage == response.pagination.totalPages" style="margin-left: 5px">Next →</button>
<div class="text-center mt-2">
<button (click)="exportForApi()">Export POST for /api/</button>
<button (click)="exportSettings()" style="margin-left: 5px">Export settings</button>
<button (click)="fileInput.click()" style="margin-left: 5px">Import settings</button>
<input type="file" #fileInput style="display: none" (change)="uploadSettingsFile($event)" accept=".json">
</div>
<div class="text-center mt-1">
<button (click)="search()" [disabled]="this.isLoading" class="mb-2" style="font-size: 18px">Search</button>
</div>
<ng-container *ngIf="this.isLoading">
<div class="text-center">
<p>Loading <app-cute-loading></app-cute-loading></p>
<p>please be patient - the database is working hard!</p>
</div>
</ng-container>
</ng-container>
</ng-template>
<div class="text-center alert-error" *ngIf="this.isError">
<p>Looks like something went wrong... :(</p>
<p>I'll look into what caused the error - but feel free to get in touch.</p>
</div>
<ng-container *ngIf="response">
<ng-container *ngIf="response.results.length <= 0">
<div class="text-center alert-error">
<p>No results for your query - try different parameters.</p>
</div>
</ng-container>
<ng-container *ngIf="response.results.length > 0">
<fieldset class="mb-2">
<legend>tools</legend>
<div class="text-center">
<button (click)="this.downloadFilesService.downloadCSV(response.results, getColumns(), 'nise-search')">Download .csv</button>
<button (click)="this.downloadFilesService.downloadJSON(response.results, 'nise-search')">Download .json</button>
<button (click)="this.downloadFilesService.downloadXLSX(response.results, 'nise-search')">Download .xlsx</button>
</div>
</fieldset>
<div class="scrollable-table">
<table class="table-border">
<thead>
<tr>
<th *ngFor="let column of fields" [hidden]="!column.active" class="text-center">
{{ column.shortName }}
</th>
<th>Links</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let entry of response.results" class="score-entry">
<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 }}
</ng-container>
<ng-container *ngIf="column.type == 'flag'">
<span class="flag" [title]="getValue(entry, column.name)">{{ countryCodeToFlag(getValue(entry, column.name)) }}</span>
</ng-container>
<ng-container *ngIf="column.type == 'grade'">
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
</ng-container>
<ng-container *ngIf="column.type == 'datetime'">
{{ getValue(entry, column.name) }}
</ng-container>
<ng-container *ngIf="column.type == 'boolean'">
<ng-container *ngIf="getValue(entry, column.name) == true">
</ng-container>
<ng-container *ngIf="getValue(entry, column.name) == false">
</ng-container>
</ng-container>
<ng-container *ngIf="column.type == 'playtime'">
{{ formatDuration(getValue(entry, column.name)) }}
</ng-container>
<ng-container *ngIf="column.type == 'string'">
<ng-container *ngIf="column.name == 'user_username'; else stringField">
<a [href]="'/u/' + getValue(entry, column.name)" target="_blank">{{ getValue(entry, column.name) }}</a>
</ng-container>
<ng-template #stringField>
{{ getValue(entry, column.name) }}
</ng-template>
</ng-container>
</ng-container>
<ng-template #nullDisplay><code>null</code></ng-template>
</td>
<td class="text-center" style="line-height: 32px">
<a [href]="this.getLink(entry)" target="_blank">
details
</a>
</td>
</tr>
</tbody>
</table>
</div>
<div class="text-center mt-2">
<p>Total results: {{ response.pagination.totalResults | number }}</p>
<p>Page: {{ response.pagination.currentPage | number }} / {{ response.pagination.totalPages | number }}</p>
<div class="mb-2">
<button *ngIf="response.pagination.currentPage > 5" (click)="this.search(1)" style="margin-right: 5px">1</button>
<span *ngIf="response.pagination.currentPage > 6">... </span>
<button *ngFor="let page of [].constructor(Math.min(response.pagination.totalPages, 10)) | calculatePageRange:response.pagination.currentPage:response.pagination.totalPages; let i = index"
(click)="this.search(page)"
[disabled]="page == response.pagination.currentPage"
style="margin-right: 5px">
{{ page }}
</button>
<span *ngIf="response.pagination.currentPage < response.pagination.totalPages - 5">... </span>
<button *ngIf="response.pagination.currentPage < response.pagination.totalPages - 4" (click)="this.search(response.pagination.totalPages)" style="margin-right: 5px">{{ response.pagination.totalPages }}</button>
</div>
<button (click)="this.search(response.pagination.currentPage - 1)" [disabled]="response.pagination.currentPage == 1">← Previous</button>
<button (click)="this.search(response.pagination.currentPage + 1)" [disabled]="response.pagination.currentPage == response.pagination.totalPages" style="margin-left: 5px">Next →</button>
</div>
</ng-container>
</ng-container>
</ng-template>
</div>
</div>

View File

@ -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<SchemaResponse>(`${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<SchemaResponse>(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<SearchResponse>(`${environment.apiUrl}/search`, body)
this.httpClient.post<SearchResponse>(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;

View File

@ -74,7 +74,7 @@ export class ViewUserComponent implements OnInit, OnChanges, OnDestroy {
getUserInfo(): Observable<UserInfo> {
const body = {
userId: this.userId
username: this.userId
}
return this.httpClient.post<UserInfo>(`${environment.apiUrl}/user-details`, body);
}

View File

@ -23,15 +23,17 @@
<select style="max-width: 60%" (change)="onFieldChange(predicate, $event)">
<option value="" disabled selected>---</option>
<ng-container *ngFor="let category of ['user', 'beatmap', 'score', 'metrics']">
<optgroup label="{{ category }}">
<ng-container *ngFor="let field of fields">
<ng-container *ngIf="field.category === category">
<option [value]="field.name" [selected]="field.name === predicate.field?.name">
{{ field.name }}
</option>
<ng-container *ngIf="hasFieldsInCategory(category)">
<optgroup label="{{ category }}">
<ng-container *ngFor="let field of fields">
<ng-container *ngIf="field.category === category">
<option [value]="field.name" [selected]="field.name === predicate.field?.name">
{{ field.name }}
</option>
</ng-container>
</ng-container>
</ng-container>
</optgroup>
</optgroup>
</ng-container>
</ng-container>
</select>

View File

@ -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;