diff --git a/.env.example b/.env.example index 8bb4f58403c..4875ae3057e 100644 --- a/.env.example +++ b/.env.example @@ -290,6 +290,7 @@ CLIENT_CHECK_VERSION=false # SCORES_EXPERIMENTAL_RANK_AS_EXTRA=false # SCORES_PROCESSING_QUEUE=osu-queue:score-statistics # SCORES_SUBMISSION_ENABLED=1 +# SCORE_INDEX_MAX_ID_DISTANCE=10_000_000 # BANCHO_BOT_USER_ID= diff --git a/app/Http/Controllers/ScoresController.php b/app/Http/Controllers/ScoresController.php index b5ecde1b294..bc3f09fc942 100644 --- a/app/Http/Controllers/ScoresController.php +++ b/app/Http/Controllers/ScoresController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers; use App\Enums\Ruleset; +use App\Models\Beatmap; use App\Models\Score\Best\Model as ScoreBest; use App\Models\ScoreReplayStats; use App\Models\Solo\Score as SoloScore; @@ -22,8 +23,9 @@ public function __construct() parent::__construct(); $this->middleware('auth', ['except' => [ - 'show', 'download', + 'index', + 'show', ]]); $this->middleware('require-scopes:public'); @@ -117,6 +119,92 @@ public function download($rulesetOrSoloId, $id = null) }, $this->makeReplayFilename($score), $responseHeaders); } + /** + * Get Scores + * + * Returns submitted scores. Up to 1000 scores will be returned in order of oldest to latest. + * Most recent scores will be returned if `cursor_string` parameter is not specified. + * + * Obtaining new scores that arrived after the last request can be done by passing `cursor_string` + * parameter from the previous request. + * + * --- + * + * ### Response Format + * + * Field | Type | Notes + * ------------- | ----------------------------- | ----- + * scores | [Score](#score)[] | | + * cursor_string | [CursorString](#cursorstring) | Same value as the request will be returned if there's no new scores + * + * @group Scores + * + * @queryParam ruleset The [Ruleset](#ruleset) to get scores for. + * @queryParam cursor_string Next set of scores + */ + public function index() + { + $params = \Request::all(); + $cursor = cursor_from_params($params); + $isOldScores = false; + if (isset($cursor['id']) && ($idFromCursor = get_int($cursor['id'])) !== null) { + $currentMaxId = SoloScore::max('id'); + $idDistance = $currentMaxId - $idFromCursor; + if ($idDistance > $GLOBALS['cfg']['osu']['scores']['index_max_id_distance']) { + abort(422, 'cursor is too old'); + } + $isOldScores = $idDistance > 10_000; + } + + $rulesetId = null; + if (isset($params['ruleset'])) { + $rulesetId = Beatmap::modeInt(get_string($params['ruleset'])); + + if ($rulesetId === null) { + abort(422, 'invalid ruleset parameter'); + } + } + + return \Cache::remember( + 'score_index:'.($rulesetId ?? '').':'.json_encode($cursor), + $isOldScores ? 600 : 5, + function () use ($cursor, $isOldScores, $rulesetId) { + $cursorHelper = SoloScore::makeDbCursorHelper('old'); + $scoresQuery = SoloScore::forListing()->limit(1_000); + if ($rulesetId !== null) { + $scoresQuery->where('ruleset_id', $rulesetId); + } + if ($cursor === null || $cursorHelper->prepare($cursor) === null) { + // fetch the latest scores when no or invalid cursor is specified + // and reverse result to match the other query (latest score last) + $scores = array_reverse($scoresQuery->orderByDesc('id')->get()->all()); + } else { + $scores = $scoresQuery->cursorSort($cursorHelper, $cursor)->get()->all(); + } + + if ($isOldScores) { + $filteredScores = $scores; + } else { + $filteredScores = []; + foreach ($scores as $score) { + // only return up to but not including the earliest unprocessed scores + if ($score->isProcessed()) { + $filteredScores[] = $score; + } else { + break; + } + } + } + + return [ + 'scores' => json_collection($filteredScores, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)), + // return previous cursor if no result, assuming there's no new scores yet + ...cursor_for_response($cursorHelper->next($filteredScores) ?? $cursor), + ]; + }, + ); + } + public function show($rulesetOrSoloId, $legacyId = null) { if ($legacyId === null) { diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index 690341baa64..ac3780ea400 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -52,7 +52,13 @@ */ class Score extends Model implements Traits\ReportableInterface { - use Traits\Reportable, Traits\WithWeightedPp; + use Traits\Reportable, Traits\WithDbCursorHelper, Traits\WithWeightedPp; + + const DEFAULT_SORT = 'old'; + + const SORTS = [ + 'old' => [['column' => 'id', 'order' => 'ASC']], + ]; public $timestamps = false; @@ -163,6 +169,13 @@ public function scopeDefault(Builder $query): Builder return $query->whereHas('beatmap.beatmapset'); } + public function scopeForListing(Builder $query): Builder + { + return $query->where('ranked', true) + ->leftJoinRelation('processHistory') + ->select([$query->qualifyColumn('*'), 'processed_version']); + } + public function scopeForRuleset(Builder $query, string $ruleset): Builder { return $query->where('ruleset_id', Beatmap::MODES[$ruleset]); @@ -364,6 +377,19 @@ public function isPerfectLegacyCombo(): ?bool return null; } + public function isProcessed(): bool + { + if ($this->legacy_score_id !== null) { + return true; + } + + if (array_key_exists('processed_version', $this->attributes)) { + return $this->attributes['processed_version'] !== null; + } + + return $this->processHistory !== null; + } + public function legacyScore(): ?LegacyScore\Best\Model { $id = $this->legacy_score_id; diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 6ab3cc8f2f5..59330f87334 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -105,7 +105,7 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) if ($score instanceof SoloScore) { $extraAttributes['classic_total_score'] = $score->getClassicTotalScore(); $extraAttributes['preserve'] = $score->preserve; - $extraAttributes['processed'] = $score->legacy_score_id !== null || $score->processHistory !== null; + $extraAttributes['processed'] = $score->isProcessed(); $extraAttributes['ranked'] = $score->ranked; } diff --git a/config/osu.php b/config/osu.php index 0bf47f7b8c4..de037d65fbd 100644 --- a/config/osu.php +++ b/config/osu.php @@ -189,6 +189,7 @@ 'es_cache_duration' => 60 * (get_float(env('SCORES_ES_CACHE_DURATION')) ?? 0.5), // in minutes, converted to seconds 'experimental_rank_as_default' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_DEFAULT')) ?? false, 'experimental_rank_as_extra' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_EXTRA')) ?? false, + 'index_max_id_distance' => get_int(env('SCORE_INDEX_MAX_ID_DISTANCE')) ?? 10_000_000, 'processing_queue' => presence(env('SCORES_PROCESSING_QUEUE')) ?? 'osu-queue:score-statistics', 'submission_enabled' => get_bool(env('SCORES_SUBMISSION_ENABLED')) ?? true, ], diff --git a/routes/web.php b/routes/web.php index f4aa9f60fc9..6e4ad7600c2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -505,6 +505,8 @@ Route::get('{rulesetOrScore}/{score}/download', 'ScoresController@download')->middleware(ThrottleRequests::getApiThrottle('scores_download'))->name('download-legacy'); Route::get('{rulesetOrScore}/{score?}', 'ScoresController@show')->name('show'); + + Route::get('/', 'ScoresController@index'); }); // Beatmapsets diff --git a/tests/api_routes.json b/tests/api_routes.json index 2fa56e80652..68fe32aac33 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -1080,6 +1080,22 @@ "public" ] }, + { + "uri": "api/v2/scores", + "methods": [ + "GET", + "HEAD" + ], + "controller": "App\\Http\\Controllers\\ScoresController@index", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:public" + ], + "scopes": [ + "public" + ] + }, { "uri": "api/v2/beatmapsets/search", "methods": [